import {cachedFetchJSON} from './cache'; const BUY = new Set([ // definitely buy 'C', 'FLX', 'H', 'H2O', 'HAL', 'HCP', 'HE', 'LST', 'MG', 'N', 'NA', 'NCS', 'NS', 'O', 'PE', 'PG', 'S', 'TCL', 'THF', // maybe buy 'AIR', 'AU', 'BE', 'BRM', 'BOR', 'BTS', 'CU', 'FAN', 'FC', 'FE', 'HCC', 'HD', 'LDI', 'LI', 'MFK', 'MWF', 'REA', 'RG', 'RGO', 'ROM', 'SFK', 'SI', 'STL', 'TCO', 'TPU', // import 'AAR', 'AWF', 'CAP', 'CF', // skip 'LFE', 'LHP', 'MFE', 'SFE', 'SSC', ]) const blueprint = { 'FFC': 1, 'FSE': 1, 'LFE': 2, 'MFE': 2, 'RCT': 1, 'SFE': 1, 'LCB': 1, 'MFL': 1, 'MSL': 1, 'LHP': 94, 'SSC': 128, 'BR1': 1, 'CQM': 1, } const shipbuilders = [ 'CraftsmanThirteen', 'EvoV', 'MapReduce', 'SurvivorBob', 'TRUEnterprises', ]; render(); async function render() { const main = document.querySelector('main.shipbuilding'); if (!main) throw new Error('missing shipbuilding container'); main.innerHTML = '

Loading...

'; const [allPrices, recipes, buildingList, knownCompanies, companyProductionById] = await Promise.all([ cachedFetchJSON('https://refined-prun.github.io/refined-prices/all.json') as Promise, recipeForMats(), cachedFetchJSON('https://api.prunplanner.org/data/buildings/') as Promise, cachedFetchJSON('https://pmmg-products.github.io/reports/data/knownCompanies.json') as Promise>, pmmgMonthlyReport(), ]); const prices = Object.fromEntries(allPrices.filter((price) => price.ExchangeCode === 'IC1') .map((price) => [price.MaterialTicker, price])); const buildings = Object.fromEntries(buildingList.map((b) => [b.building_ticker, b])); const production: Production = {}; const extract: Record = {}; const buy: Record = {}; 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; analysisNodes.push(node); } // requiredMats = buy + production const requiredMats: Record = {...buy}; for (const buildingProduction of Object.values(production)) for (const [mat, amount] of Object.entries(buildingProduction)) requiredMats[mat] = (requiredMats[mat] ?? 0) + amount; const expertiseGroups: Record = {}; for (const building of buildingList) { if (!(building.building_ticker in production)) continue; if (!expertiseGroups[building.expertise]) expertiseGroups[building.expertise] = []; expertiseGroups[building.expertise].push(building.building_ticker); } main.innerHTML = ''; main.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), ); } function renderMatList(header: string, mats: Record): HTMLElement { const section = element('section'); section.append(element('h2', {textContent: header})); const matsSorted = Object.entries(mats).sort(([a], [b]) => a.localeCompare(b)); section.append(element('p', { textContent: matsSorted.map(([mat, amount]) => `${formatAmount(amount)}x${mat}`).join(', '), })); return section; } async function pmmgMonthlyReport(): Promise { 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, knownCompanies: Record, companyProductionById: PMMGCompanyProduction): HTMLElement { const matTickers = Object.keys(requiredMats).sort(); const shipbuilderSet = new Set(shipbuilders); const shipbuildersById: Record = {}; 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> { const allRecipes: Recipe[] = await cachedFetchJSON('https://api.prunplanner.org/data/recipes/'); const matRecipes: Record = {}; // all ways to make a mat for (const recipe of allRecipes) for (const output of recipe.outputs) { const recipes = matRecipes[output.material_ticker]; if (recipes) recipes.push(recipe); else matRecipes[output.material_ticker] = [recipe]; } const matRecipe: Record = {}; // mats for which there's only one recipe to make for (const [mat, recipes] of Object.entries(matRecipes)) if (recipes.length === 1) matRecipe[mat] = recipes[0]; return matRecipe; } function analyzeMat(mat: string, amount: number, production: Production, extract: Record, buy: Record, prices: Record, recipes: Record): { cost: number, node: AnalysisNode } { const price = prices[mat]; if (!price) throw new Error(`missing price for ${mat}`); const traded = price.AverageTraded30D ?? 0; if (BUY.has(mat)) { if (price.Ask == null) throw new Error(`missing ask price for ${mat}`); buy[mat] = (buy[mat] ?? 0) + amount; return { cost: price.Ask * amount, node: { text: `${formatAmount(amount)}x${mat} buy: ${formatAmount(price.Ask)}, 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: [] } }; } const building = recipe.building_ticker; if (!production[building]) production[building] = {}; production[building][mat] = (production[building][mat] ?? 0) + amount; const liquid = traded > amount * 2 ? 'liquid' : 'not liquid'; let totalCost = 0; 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; children.push(node); } children.push({ text: `cost: ${formatWhole(totalCost)}`, children: [] }); return { cost: totalCost, node: { text: `${formatAmount(amount)}x${mat} make (${building}, ${liquid})`, children }, }; } function renderAnalysis(nodes: AnalysisNode[]): HTMLElement { const section = element('section'); for (const node of nodes) section.append(renderAnalysisNode(node)); return section; } function renderAnalysisNode(node: AnalysisNode, level = 0): HTMLElement { let el; if (node.children.length === 0) { el = element('div', {textContent: node.text, className: 'analysis-node'}); } else { el = element('details', {className: 'analysis-node', open: true}); el.append(element('summary', {textContent: node.text})); for (const child of node.children) el.append(renderAnalysisNode(child, level + 1)); } if (level === 0) el.classList.add('root'); return el; } const WORKER_CONSUMPTION: Record<'pioneers' | 'settlers' | 'technicians' | 'engineers' | 'scientists', Record> = { pioneers: {'COF': 0.5, 'DW': 4, 'RAT': 4, 'OVE': 0.5, 'PWO': 0.2}, settlers: {'DW': 5, 'RAT': 6, 'KOM': 1, 'EXO': 0.5, 'REP': 0.2, 'PT': 0.5}, technicians: {'DW': 7.5, 'RAT': 7, 'ALE': 1, 'MED': 0.5, 'SC': 0.1, 'HMS': 0.5, 'SCN': 0.1}, engineers: {'DW': 10, 'MED': 0.5, 'GIN': 1, 'FIM': 7, 'VG': 0.2, 'HSS': 0.2, 'PDA': 0.1}, scientists: {'DW': 10, 'MED': 0.5, 'WIN': 1, 'MEA': 7, 'NST': 0.1, 'LC': 0.2, 'WS': 0.05}, }; function buildingDailyCost(building: Building, prices: Record): number { let cost = 0; for (const [workerType, mats] of Object.entries(WORKER_CONSUMPTION)) { const workers = building[workerType as keyof typeof WORKER_CONSUMPTION]; for (const [mat, per100] of Object.entries(mats)) { const price = prices[mat].VWAP30D; if (price == null) throw new Error(`no price for ${mat}`); cost += price * workers * per100 / 100; } } return cost; } function renderProduction(expertiseGroups: Record, production: Production, prices: Record, recipes: Record, buildings: Record): HTMLElement { const section = element('section'); section.append(element('h2', {textContent: 'production'})); let totalConsumablesCost = 0; for (const [expertise, productionBuildings] of Object.entries(expertiseGroups)) { section.append(element('h3', {textContent: expertise})); for (const building of productionBuildings) { const buildingMats = element('div'); const mats = Object.entries(production[building]); let buildingMins = 0; for (const [index, [mat, amount]] of mats.entries()) { const traded = prices[mat]?.AverageTraded30D ?? 0; const span = element('span', { textContent: `${formatAmount(amount)}x${mat}`, }); span.style.color = traded > amount * 2 ? '#0cc' : '#c70'; buildingMats.append(span); if (index < mats.length - 1) buildingMats.append(document.createTextNode(' ')); const recipe = recipes[mat]; const outputPerRun = recipe.outputs.find((o) => o.material_ticker === mat)!.material_amount; buildingMins += amount / outputPerRun * (recipe.time_ms / 1000 / 60); } const numBuildings = buildingMins / (24*60) / 5 / 1.605; // one ship every 5 days, 160.5% efficiency const consumablesCost = buildingDailyCost(buildings[building], prices) * Math.round(Math.max(1, numBuildings)); totalConsumablesCost += consumablesCost; const buildingRow = element('div', {className: 'building-row', textContent: `${formatFixed(numBuildings, 1)}x${building} (${formatWhole(consumablesCost)}/d)`}); buildingRow.append(buildingMats); section.append(buildingRow); } } section.append(element('h4', {textContent: `total consumables cost: ${formatWhole(totalConsumablesCost)}/day, ${formatWhole(totalConsumablesCost * 5)}/ship`})); return section; } function element(tagName: K, properties: Partial = {}): HTMLElementTagNameMap[K] { const node = document.createElement(tagName); Object.assign(node, properties); return node; } function formatAmount(n: number): string { return n.toLocaleString(undefined, {maximumFractionDigits: 3}); } function formatFixed(n: number, digits: number): string { return n.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function formatWhole(n: number): string { return n.toLocaleString(undefined, {maximumFractionDigits: 0}); } interface AnalysisNode { text: string children: AnalysisNode[] } interface Recipe { recipe_name: string building_ticker: string inputs: RecipeMat[] outputs: RecipeMat[] time_ms: number } interface RecipeMat { material_ticker: string material_amount: number } interface RawPrice { MaterialTicker: string ExchangeCode: string Ask: number | null AverageTraded30D: number | null VWAP30D: number | null Supply: number } interface Building { building_ticker: string expertise: string pioneers: number settlers: number technicians: number engineers: number scientists: number } type PMMGCompanyProduction = Record>; type Production = Record>;