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', 'CU', 'FAN', 'FC', 'FE', 'HCC', 'HD', 'LDI', 'LI', 'MFK', 'MWF', 'REA', 'RG', 'RGO', 'ROM', 'SFK', 'SI', 'STL', 'TI', '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',
'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, buildings, 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 production: Production = {};
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, 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 buildings) {
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),
renderBuyMaterials(buy),
renderShipbuilders(requiredMats, knownCompanies, companyProductionById),
);
}
function renderBuyMaterials(buy: Record): HTMLElement {
const section = element('section');
section.append(element('h2', {textContent: 'buy'}));
const mats = Object.entries(buy).sort(([a], [b]) => a.localeCompare(b));
section.append(element('p', {
textContent: mats.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, 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)
return { cost: 0, node: { text: `${formatAmount(amount)}x${mat} make (unknown recipe)`, 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, 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;
}
function renderProduction(expertiseGroups: Record, production: Production,
prices: Record, recipes: Record): HTMLElement {
const section = element('section');
section.append(element('h2', {textContent: 'production'}));
for (const [expertise, buildings] of Object.entries(expertiseGroups)) {
section.append(element('h3', {textContent: expertise}));
for (const building of buildings) {
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 buildingRow = element('div', {className: 'building-row', textContent: `${formatFixed(numBuildings, 1)}x${building}`});
buildingRow.append(buildingMats);
section.append(buildingRow);
}
}
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
Supply: number
}
interface Building {
building_ticker: string
expertise: string
}
type PMMGCompanyProduction = Record>;
type Production = Record>;