3 Commits 779244a2cf ... effe8d75a6

Author SHA1 Message Date
  raylu effe8d75a6 shipbuilding: show amount in storage 3 weeks ago
  raylu 2545b7afe9 shipbuilding: stop rendering shipbuilders 4 weeks ago
  raylu 43421ad20d mat_competitors: estimate the fraction of production that is local 4 weeks ago
4 changed files with 123 additions and 107 deletions
  1. 30 16
      mat_competitors.py
  2. 0 1
      ts/ledger.ts
  3. 85 89
      ts/shipbuilding.ts
  4. 8 1
      www/shipbuilding.html

+ 30 - 16
mat_competitors.py

@@ -16,25 +16,28 @@ def main() -> None:
 
 	(expertise,) = frozenset(iter_expertise(ticker))
 
-	with open('www/closest.json') as f:
-		planet_ids = {planet_id for planet_id, closest_cx in json.load(f).items() if closest_cx == cx}
-
-	planets = []
+	planets: dict[str, str] = {}
 	for planet, cogc in company.iter_planet_cogc():
-		if planet['PlanetId'] in planet_ids and cogc == expertise:
-			planets.append(planet['PlanetName'])
-	print(len(planets), expertise, 'planets near', cx)
+		if cogc == expertise:
+			planets[planet['PlanetId']] = planet['PlanetName']
+	print(len(planets), 'with', expertise, 'CoGC')
+
+	with open('www/closest.json') as f:
+		close_planet_ids = {planet_id for planet_id, closest_cx in json.load(f).items() if closest_cx == cx}
 
 	coid_code_name: dict[str, tuple[str, str]] = {}
 	coid_bases = collections.defaultdict(list)
-	for planet in planets:
-		print('\t' + planet)
-		bases = planet_bases.get_bases(planet)
+	for planet_id, planet_name in planets.items():
+		if planet_id in close_planet_ids:
+			print(f'\t\033[32m{planet_name}\033[0m')
+		else:
+			print(f'\t\033[31m{planet_name}\033[0m')
+		bases = planet_bases.get_bases(planet_name)
 		for base in bases:
 			if (code := base['OwnerCode']) is None:
 				continue
 			coid_code_name[base['OwnerId']] = code, base['OwnerName']
-			coid_bases[base['OwnerId']].append(planet)
+			coid_bases[base['OwnerId']].append(planet_id)
 
 	coid_users: dict[str, str] = {company_id: d['Username']
 		for company_id, d in cache.get('https://pmmg-products.github.io/reports/data/knownCompanies.json', expiry=cache.ONE_DAY).items()}
@@ -43,16 +46,27 @@ def main() -> None:
 	for company_id, co_production in integration.pmmg_monthly_report().items():
 		if (mat_production := co_production.get(ticker)) is None:
 			continue
-		if bases := coid_bases.get(company_id):
+		if planet_ids := coid_bases.get(company_id):
 			code, co_name = coid_code_name[company_id]
 			username = coid_users[company_id]
-			competitors.append(Competitor(code, co_name, username, mat_production['amount'], bases))
+			competitors.append(Competitor(code, co_name, username, mat_production['amount'], planet_ids))
 	competitors.sort(reverse=True)
 
 	total = 0.0
 	for c in competitors:
-		print(f'{c.code:4} {c.company_name:30} {c.username:20} {c.production:9,.1f}', ', '.join(c.bases))
-		total += c.production
+		close_planet_num = 0
+		player_planets = []
+		for planet_id in c.planet_ids:
+			if planet_id in close_planet_ids:
+				player_planets.append(f'\033[32m{planets[planet_id]}\033[0m')
+				close_planet_num += 1
+			else:
+				player_planets.append(f'\033[31m{planets[planet_id]}\033[0m')
+
+		if close_planet_num > 0:
+			local_production = c.production * close_planet_num / len(c.planet_ids)
+			total += local_production
+			print(f'{c.code:4} {c.company_name:30} {c.username:20} {local_production:9,.1f} ', ' '.join(player_planets))
 	print(f'total: {total:,.1f}')
 
 def iter_expertise(ticker: str) -> typing.Iterator[str]:
@@ -70,7 +84,7 @@ class Competitor:
 	company_name: str
 	username: str
 	production: float
-	bases: typing.Sequence[str]
+	planet_ids: typing.Sequence[str]
 
 	def __lt__(self, other: Competitor) -> bool:
 		return self.production < other.production

+ 0 - 1
ts/ledger.ts

@@ -6,7 +6,6 @@ const apiKey = document.querySelector('#api-key') as HTMLInputElement;
 }
 document.querySelector('#fetch')!.addEventListener('click', async () => {
 	const loader = document.querySelector('#loader') as HTMLElement;
-	loader.innerHTML = '';
 	loader.style.display = 'block';
 	try {
 		await renderLedger(apiKey.value);

+ 85 - 89
ts/shipbuilding.ts

@@ -29,31 +29,36 @@ const blueprint = {
 	'CQM': 1,
 }
 
-const shipbuilders = [
-	'CraftsmanThirteen',
-	'EvoV',
-	'MapReduce',
-	'SurvivorBob',
-	'TRUEnterprises',
-];
-
-const main = document.querySelector('main.shipbuilding')!;
-(async () => {
-	setupPopover();
-	main.innerHTML = 'loading...';
+const apiKey = document.querySelector('#api-key') as HTMLInputElement;
+{
+	const storedApiKey = localStorage.getItem('punoted-api-key');
+	if (storedApiKey)
+		apiKey.value = storedApiKey;
+}
+document.querySelector('#fetch')!.addEventListener('click', render);
+
+const renderTarget = document.querySelector('div#shipbuilding')!;
+async function render() {
+	const loader = document.querySelector('#loader') as HTMLElement;
+	loader.style.display = 'block';
 	try {
-		await render();
+		await _render();
+		if (apiKey.value)
+			localStorage.setItem('punoted-api-key', apiKey.value);
 	} catch (e) {
-		main.innerHTML = e instanceof Error ? e.message : String(e);
+		renderTarget.innerHTML = e instanceof Error ? e.message : String(e);
 	}
-})();
-async function render() {
-	const [allPrices, recipes, buildingList, knownCompanies, companyProductionById] = await Promise.all([
+	loader.style.display = 'none';
+}
+setupPopover();
+render();
+
+async function _render() {
+	const [allPrices, recipes, buildingList, storage] = await Promise.all([
 		cachedFetchJSON('https://refined-prun.github.io/refined-prices/all.json') as Promise<RawPrice[]>,
 		recipeForMats(),
 		cachedFetchJSON('https://api.prunplanner.org/data/buildings/') as Promise<Building[]>,
-		cachedFetchJSON('https://pmmg-products.github.io/reports/data/knownCompanies.json') as Promise<Record<string, {Username: string}>>,
-		pmmgMonthlyReport(),
+		fetchStorage(),
 	]);
 	const prices = Object.fromEntries(allPrices.filter((price) => price.ExchangeCode === 'IC1')
 			.map((price) => [price.MaterialTicker, price]));
@@ -65,8 +70,8 @@ async function render() {
 	const analysisNodes: AnalysisNode[] = [];
 	let cost = 0;
 	for (const [mat, amount] of Object.entries(blueprint)) {
-		const { cost: matCost, node } = analyzeMat(mat, amount, production, extract, buy, prices, recipes);
-		cost += matCost;
+		const node = analyzeMat(mat, amount, production, extract, buy, prices, recipes, storage);
+		cost += node.cost;
 		analysisNodes.push(node);
 	}
 
@@ -85,14 +90,13 @@ async function render() {
 		expertiseGroups[building.expertise].push(building.building_ticker);
 	}
 
-	main.innerHTML = '';
-	main.append(
+	renderTarget.innerHTML = '';
+	renderTarget.append(
 		renderAnalysis(analysisNodes),
 		element('p', {textContent: `total cost: ${formatWhole(cost)}`}),
 		renderProduction(expertiseGroups, production, prices, recipes, buildings),
 		renderMatList('extract', extract),
 		renderMatList('buy', buy),
-		renderShipbuilders(requiredMats, knownCompanies, companyProductionById),
 	);
 }
 
@@ -106,59 +110,6 @@ function renderMatList(header: string, mats: Record<string, number>): HTMLElemen
 	return section;
 }
 
-async function pmmgMonthlyReport(): Promise<PMMGCompanyProduction> {
-	const constants = await fetch('https://raw.githubusercontent.com/PMMG-Products/pmmg-products.github.io/main/reports/src/staticData/constants.ts')
-			.then((res) => res.text());
-	const match = constants.match(/export const months = \[(.*?)\];/s);
-	if (!match)
-		throw new Error('failed to parse PMMG months');
-	const months = match[1].split(',').map((m) => m.trim().replace(/^"|"$/g, ''));
-	const latestMonth = months.at(-1);
-	if (!latestMonth)
-		throw new Error('missing PMMG month');
-	const report = await cachedFetchJSON(`https://pmmg-products.github.io/reports/data/company-data-${latestMonth}.json`);
-	return report['individual'];
-}
-
-function renderShipbuilders(requiredMats: Record<string, number>,
-		knownCompanies: Record<string, {Username: string}>, companyProductionById: PMMGCompanyProduction): HTMLElement {
-	const matTickers = Object.keys(requiredMats).sort();
-	const shipbuilderSet = new Set(shipbuilders);
-	const shipbuildersById: Record<string, string> = {};
-	for (const [companyId, company] of Object.entries(knownCompanies))
-		if (shipbuilderSet.has(company.Username))
-			shipbuildersById[companyId] = company.Username;
-
-	const missingShipbuilders = shipbuilders
-		.filter((username) => !Object.values(shipbuildersById).includes(username));
-	if (missingShipbuilders.length > 0)
-		return element('h3', {textContent: `missing shipbuilders in PMMG report: ${missingShipbuilders.join(', ')}`});
-
-	const section = element('section');
-	section.append(element('h2', {textContent: 'shipbuilders'}));
-
-	const table = element('table');
-	const header = element('tr');
-	header.append(element('th', {textContent: 'mat'}));
-	header.append(element('th', {textContent: 'amount'}));
-	for (const username of Object.values(shipbuildersById))
-		header.append(element('th', {textContent: username}));
-	table.append(header);
-
-	for (const mat of matTickers) {
-		const row = element('tr');
-		row.append(element('td', {textContent: mat}));
-		row.append(element('td', {textContent: formatAmount(requiredMats[mat])}));
-		for (const companyId of Object.keys(shipbuildersById)) {
-			const makes = mat in companyProductionById[companyId];
-			row.append(element('td', {textContent: makes ? '' : 'x'}));
-		}
-		table.append(row);
-	}
-	section.append(table);
-	return section;
-}
-
 async function recipeForMats(): Promise<Record<string, Recipe>> {
 	const allRecipes: Recipe[] = await cachedFetchJSON('https://api.prunplanner.org/data/recipes/');
 	const matRecipes: Record<string, Recipe[]> = {}; // all ways to make a mat
@@ -179,25 +130,32 @@ async function recipeForMats(): Promise<Record<string, Recipe>> {
 }
 
 function analyzeMat(mat: string, amount: number, production: Production, extract: Record<string, number>, buy: Record<string, number>,
-		prices: Record<string, RawPrice>, recipes: Record<string, Recipe>): { cost: number, node: AnalysisNode } {
+		prices: Record<string, RawPrice>, recipes: Record<string, Recipe>, storage: Record<string, number>): AnalysisNode {
 	const price = prices[mat];
 	if (!price)
 		throw new Error(`missing price for ${mat}`);
 	const traded = price.AverageTraded30D ?? 0;
+
+	const inStorage = storage[mat] ?? 0;
+
 	if (BUY.has(mat)) {
 		const matPrice = price.VWAP30D ?? price.Ask;
 		if (matPrice == null) throw new Error(`missing ask price for ${mat}`);
 		buy[mat] = (buy[mat] ?? 0) + amount;
 		return {
+			ticker: mat,
+			amount,
+			inStorage,
+			acquisition: `buy: ${formatAmount(matPrice)}, daily traded ${formatFixed(traded, 1)}`,
+			children: [],
 			cost: matPrice * amount,
-			node: { text: `${formatAmount(amount)}x${mat} buy: ${formatAmount(matPrice)}, daily traded ${formatFixed(traded, 1)}`, children: [] },
 		};
 	}
 
 	const recipe = recipes[mat];
 	if (!recipe) {
 		extract[mat] = (extract[mat] ?? 0) + amount;
-		return { cost: 0, node: { text: `${formatAmount(amount)}x${mat} extract`, children: [] } };
+		return {ticker: mat, amount, inStorage, acquisition: `extract`, children: [], cost: 0};
 	}
 
 	const building = recipe.building_ticker;
@@ -211,14 +169,17 @@ function analyzeMat(mat: string, amount: number, production: Production, extract
 	const children: AnalysisNode[] = [];
 	for (const inputMat of recipe.inputs) {
 		const inputAmount = inputMat.material_amount * amount / recipe.outputs[0].material_amount;
-		const {cost, node} = analyzeMat(inputMat.material_ticker, inputAmount, production, extract, buy, prices, recipes);
-		totalCost += cost;
+		const node = analyzeMat(inputMat.material_ticker, inputAmount, production, extract, buy, prices, recipes, storage);
+		totalCost += node.cost;
 		children.push(node);
 	}
-	children.push({ text: `cost: ${formatWhole(totalCost)}`, children: [] });
 	return {
+		ticker: mat,
+		amount,
+		inStorage,
+		acquisition: `make (${building}, ${liquid})`,
+		children,
 		cost: totalCost,
-		node: { text: `${formatAmount(amount)}x${mat} make (${building}, ${liquid})`, children },
 	};
 }
 
@@ -230,14 +191,24 @@ function renderAnalysis(nodes: AnalysisNode[]): HTMLElement {
 }
 
 function renderAnalysisNode(node: AnalysisNode, level = 0): HTMLElement {
+	const amountText = element('span', {textContent: `${formatAmount(node.amount)}x${node.ticker} `});
+	const storageText = element('span', {textContent: `(${formatAmount(node.inStorage)})`});
+	const percent = Math.min(node.inStorage / node.amount, 1);
+	storageText.style.color = `color-mix(in xyz, #0aa ${percent * 100}%, #f80 )`;
+	const acquisitionText = element('span', {textContent: ' ' + node.acquisition});
+
 	let el;
 	if (node.children.length === 0) {
-		el = element('div', {textContent: node.text, className: 'analysis-node'});
+		el = element('div', {className: 'analysis-node'});
+		el.append(amountText, storageText, acquisitionText);
 	} else {
-		el = element('details', {className: 'analysis-node', open: level > 0});
-		el.append(element('summary', {textContent: node.text}));
+		el = element('details', {className: 'analysis-node', open: level > 0 && percent < 1});
+		const summary = element('summary');
+		summary.append(amountText, storageText, acquisitionText);
+		el.append(summary);
 		for (const child of node.children)
 			el.append(renderAnalysisNode(child, level + 1));
+		el.append(document.createTextNode(`total cost: ${formatWhole(node.cost)}`));
 	}
 	if (level === 0)
 		el.classList.add('root');
@@ -345,6 +316,19 @@ function renderProduction(expertiseGroups: Record<string, string[]>, production:
 	return section;
 }
 
+async function fetchStorage(): Promise<Record<string, number>> {
+	if (!apiKey.value)
+		return {};
+	const users = await fetch('https://api.punoted.net/v1/storages/', {headers: {'X-Data-Token': apiKey.value}})
+			.then((r) => r.json()) as PUNUserStore[];
+	const items: Record<string, number> = {};
+	for (const user of users)
+		for (const storage of user.Storages)
+			for (const item of storage.StorageItems)
+				items[item.MaterialTicker] = (items[item.MaterialTicker] ?? 0) + item.MaterialAmount;
+	return items;
+}
+
 function element<K extends keyof HTMLElementTagNameMap>(tagName: K,
 		properties: Partial<HTMLElementTagNameMap[K]> = {}): HTMLElementTagNameMap[K] {
 	const node = document.createElement(tagName);
@@ -368,8 +352,12 @@ function formatWhole(n: number): string {
 }
 
 interface AnalysisNode {
-	text: string
+	ticker: string
+	amount: number
+	inStorage: number
+	acquisition: string
 	children: AnalysisNode[]
+	cost: number
 }
 
 interface Recipe {
@@ -404,5 +392,13 @@ interface Building {
 	scientists: number
 }
 
-type PMMGCompanyProduction = Record<string, Record<string, {amount: number}>>;
+interface PUNUserStore {
+	Storages: Array<{
+		StorageItems: Array<{
+			MaterialTicker: string
+			MaterialAmount: number
+		}>
+	}>
+}
+
 type Production = Record<string, Record<string, number>>;

+ 8 - 1
www/shipbuilding.html

@@ -10,7 +10,14 @@
 </head>
 <body>
 	<a href="/">← back</a>
-	<main class="shipbuilding"></main>
+	<main class="shipbuilding">
+		<form>
+			<label>PUNoted API key: <input type="password" size="30" id="api-key"></label>
+			<input type="button" value="fetch" id="fetch">
+		</form>
+		<section id="loader"></section>
+		<div id="shipbuilding"></div>
+	</main>
 	<div id="popover" popover="hint"></div>
 	<script src="shipbuilding.js"></script>
 </body>