|
|
@@ -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>>;
|