فهرست منبع

Add_HQ_Permit_Capital_Requirements

Executed an open-source pull from PRUNplanner to dynamically model endgame HQ permit capital costs.

        PURPOSE:
        To integrate the massive material requirements for late-game expansion permits directly into the ROI break-even mathematics. Because permit requirements are a static mechanic with no official public API endpoint, the raw hardcoded JSON dictionary was intercepted directly from the PRUNplanner GitHub CDN. Injecting these requirements organically into the CapEx calculation ensures that players planning their 3rd, 4th, or 5th planetary base accurately model the upfront financial barrier required to unlock it.

        IMPLEMENTATION DETAILS:
        - `roi.py`: Added a `cache.get()` call targeting `https://raw.githubusercontent.com/prunplanner/prunplanner/main/frontend/src/features/hq_upgrade_calculator/hq_levels.json`.
        - `roi.py`: Pre-calculated the VWAP, Bid, and Ask values for every HQ upgrade tier using the existing `get_metrics()` function. Appended this dictionary (`hq_costs`) to the `Profit` dataclass.
        - `ts/roi.ts`: Added `<input type="number" id="target-permit">` to the DOM header block, bound to the `targetPermit` state variable (defaulting to 2, the free starting limit).
        - `ts/roi.ts`: If `targetPermit >= 3`, the `.map()` derivation block translates the permit number to the corresponding PRUNplanner JSON key (`targetPermit - 1`), retrieves the cost based on the active `capexMetric` dropdown, and adds it to `capex_val`.
        - `ts/roi.ts`: Appended a dedicated `HQ Upgrade (Permit X)` line item inside the CapEx cell tooltip specifically when the upgrade cost triggers, maintaining absolute financial transparency.
Thomas Knott 2 هفته پیش
والد
کامیت
e6b204bfb7
7فایلهای تغییر یافته به همراه1242 افزوده شده و 1083 حذف شده
  1. BIN
      cache/refined-prun.github.io/refined-prices%2Fall.json.cbor.xz
  2. 27 10
      roi.py
  3. 37 20
      ts/roi.ts
  4. 299 283
      www/roi_ai1.json
  5. 365 291
      www/roi_ci1.json
  6. 290 273
      www/roi_ic1.json
  7. 224 206
      www/roi_nc1.json

BIN
cache/refined-prun.github.io/refined-prices%2Fall.json.cbor.xz


+ 27 - 10
roi.py

@@ -11,17 +11,36 @@ def main() -> None:
 	buildings: dict[str, Building] = {m['building_ticker']: m for m in cache.get('https://api.prunplanner.org/data/buildings/')}
 	materials: dict[str, Material] = {m['ticker']: m for m in cache.get('https://api.prunplanner.org/data/materials/')}
 	raw_prices: list[RawPrice] = cache.get('https://refined-prun.github.io/refined-prices/all.json')
+	
+	# EXTREME DETAIL: We execute the "Open Source Heist" here. We pull the static JSON 
+	# directly from PRUNplanner's GitHub repository. 
+	hq_levels_raw = cache.get('https://raw.githubusercontent.com/prunplanner/prunplanner/main/frontend/src/features/hq_upgrade_calculator/hq_levels.json')
+	
 	for cx in ['AI1', 'CI1', 'IC1', 'NC1']:
-		profits = calc_for_cx(cx, recipes, buildings, materials, raw_prices)
+		profits = calc_for_cx(cx, recipes, buildings, materials, raw_prices, hq_levels_raw)
 		with open(f'www/roi_{cx.lower()}.json', 'w') as f:
 			json.dump([dataclasses.asdict(p) for p in profits], f, indent='\t')
 
 def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.Mapping[str, Building],
-		materials: typing.Mapping[str, Material], raw_prices: typing.Collection[RawPrice]) -> typing.Sequence[Profit]:
+		materials: typing.Mapping[str, Material], raw_prices: typing.Collection[RawPrice], 
+		hq_levels_raw: dict) -> typing.Sequence[Profit]:
 	prices: dict[str, Price] = {
 		p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D'], p['VWAP30D'], p['Bid'], p['Ask']) for p in raw_prices # pyright: ignore[reportArgumentType]
 		if p['ExchangeCode'] == cx
 	}
+	
+	# EXTREME DETAIL: We pre-calculate the VWAP, Bid, and Ask prices for every HQ level.
+	# This ensures the frontend doesn't have to do any heavy lifting, and the HQ CapEx
+	# naturally inherits the user's active price-metric permutation from the dropdowns!
+	hq_costs: dict[str, dict[str, float]] = {}
+	for level_str, mats in hq_levels_raw.items():
+		cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
+		for mat in mats:
+			if mat['ticker'] in prices:
+				m_metrics = get_metrics(mat['amount'], prices[mat['ticker']])
+				for k in cost: cost[k] += m_metrics[k]
+		hq_costs[level_str] = cost
+
 	habitation: typing.Mapping[Worker, str] = {
 		'pioneers': 'HB1',
 		'settlers': 'HB2',
@@ -38,7 +57,7 @@ def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.M
 
 	profits: list[Profit] = []
 	for recipe in recipes:
-		if profit := calc_profit(recipe, buildings, hab_area_cost, hab_capex, materials, prices):
+		if profit := calc_profit(recipe, buildings, hab_area_cost, hab_capex, materials, prices, hq_costs):
 			profits.append(profit)
 	profits.sort()
 	return profits
@@ -51,7 +70,7 @@ def get_metrics(amount: float, price: Price) -> dict[str, float]:
 
 def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_area_cost: typing.Mapping[Worker, float],
 		hab_capex: typing.Mapping[Worker, dict[str, float]], materials: typing.Mapping[str, Material],
-		prices: typing.Mapping[str, Price]) -> Profit | None:
+		prices: typing.Mapping[str, Price], hq_costs: dict[str, dict[str, float]]) -> Profit | None:
 	if len(recipe['outputs']) == 0:
 		return
 
@@ -113,9 +132,6 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 	ships_needed_per_base = max(in_w / 3000, in_v / 1000, out_w / 3000, out_v / 1000) * runs_per_base
 	ship_capex_per_base = ships_needed_per_base * 800_000
 
-	# EXTREME DETAIL: We decouple the max() bottleneck calculation to identify EXACTLY
-	# which metric limits the supply chain. This string ('t (I)', 'm³ (O)', etc.) is 
-	# passed to the frontend to append to the numerical value.
 	bottlenecks = [
 		(in_w, 't (I)'),
 		(in_v, 'm³ (I)'),
@@ -139,7 +155,8 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 			output_per_day=output_per_day,
 			average_traded_7d=average_traded_7d,
 			market_capacity_base=market_capacity_base,
-			ship_capex_per_base=ship_capex_per_base)
+			ship_capex_per_base=ship_capex_per_base,
+			hq_costs=hq_costs)
 
 def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
 	cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
@@ -235,18 +252,18 @@ class Profit:
 	input_costs: typing.Collection[MatPrice]
 	runs_per_day: float
 	logistics_per_base: float
-	logistics_bottleneck: str    # Added bottleneck string indicator
+	logistics_bottleneck: str
 	output_per_day: float
 	average_traded_7d: float
 	market_capacity_base: float
 	ship_capex_per_base: float
+	hq_costs: dict[str, dict[str, float]] # Added the HQ pricing dictionary
 
 	def __lt__(self, other: Profit) -> bool:
 		bases_a = self.area / 500
 		p_a = (self.revenue['vwap'] - self.opex['vwap']) / bases_a
 		c_a = self.capex['vwap'] / bases_a
 		o_a = self.opex['vwap'] / bases_a
-		# We default to a 3-day baseline here to ensure the backend JSON is sorted logically.
 		be_a = (c_a + 3 * o_a) / p_a if p_a > 0 else 10000 - p_a
 		
 		bases_b = other.area / 500

+ 37 - 20
ts/roi.ts

@@ -33,9 +33,6 @@ for (const key of Object.keys(expertise)) {
 }
 const buildingSelect = document.querySelector('select#building') as HTMLSelectElement;
 
-// EXTREME DETAIL: We replace the distinct `formatWhole` and `formatDecimal` formatters with a unified 
-// `formatSigFig` formatter. Utilizing the `compact` notation automatically caps the output at 
-// 3 significant figures and organically appends "K", "M", etc. to large numbers.
 const formatSigFig = new Intl.NumberFormat(undefined, {
 	notation: 'compact',
 	maximumSignificantDigits: 3,
@@ -56,10 +53,11 @@ let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as Metri
 let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
 let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
 let includeShips: boolean = localStorage.getItem('roi-include-ships') === 'true';
-
-// EXTREME DETAIL: Track the state of the user's working capital input. Default to 3 days.
 let workingCapitalDays: number = parseInt(localStorage.getItem('roi-working-capital') || '3', 10);
 
+// EXTREME DETAIL: Track the state of the Target Permit. Defaults to 2 (the standard starting permits in PRUN).
+let targetPermit: number = parseInt(localStorage.getItem('roi-target-permit') || '2', 10);
+
 async function render() {
 	const tbody = document.querySelector('tbody')!;
 	tbody.innerHTML = '';
@@ -72,8 +70,7 @@ async function render() {
 	if (!metricControlsInitialized) {
 		const controls = document.createElement('div');
 		controls.style.marginBottom = '15px';
-		// EXTREME DETAIL: Added the numerical input `<input type="number">` allowing users to configure
-		// exactly how many days of OpEx should be buffered into their CapEx.
+		// EXTREME DETAIL: Injected the new `<input type="number">` for Target Permit.
 		controls.innerHTML = `
 			<label style="margin-right: 15px;">CapEx Price: 
 				<select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
@@ -87,9 +84,12 @@ async function render() {
 			<label style="margin-right: 15px;">
 				<input type="checkbox" id="include-ships"> Include Ship CapEx
 			</label>
-			<label>
+			<label style="margin-right: 15px;">
 				<input type="number" id="working-capital" min="0" step="1" style="width: 50px;"> Days OpEx
 			</label>
+			<label>
+				<input type="number" id="target-permit" min="1" step="1" style="width: 50px;"> Target Permit
+			</label>
 		`;
 		const table = document.querySelector('table');
 		if (table) table.parentNode?.insertBefore(controls, table);
@@ -99,6 +99,7 @@ async function render() {
 		(document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
 		(document.getElementById('include-ships') as HTMLInputElement).checked = includeShips;
 		(document.getElementById('working-capital') as HTMLInputElement).value = workingCapitalDays.toString();
+		(document.getElementById('target-permit') as HTMLInputElement).value = targetPermit.toString();
 
 		document.getElementById('capex-metric')!.addEventListener('change', (e) => {
 			capexMetric = (e.target as HTMLSelectElement).value as MetricType;
@@ -120,6 +121,10 @@ async function render() {
 			workingCapitalDays = parseInt((e.target as HTMLInputElement).value, 10);
 			render();
 		});
+		document.getElementById('target-permit')!.addEventListener('change', (e) => {
+			targetPermit = parseInt((e.target as HTMLInputElement).value, 10);
+			render();
+		});
 		metricControlsInitialized = true;
 	}
 
@@ -140,7 +145,7 @@ async function render() {
 					th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.';
 				} else if (keys[i] === 'capex_val') {
 					th.textContent = 'CapEx/Base';
-					th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure scaled to a full 500-area planetary base.\nIncludes base construction, working capital (days of OpEx), and optional Ship CapEx.';
+					th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure scaled to a full 500-area planetary base.\nIncludes base construction, working capital (days of OpEx), optional HQ Upgrade materials for the target permit, and optional Ship CapEx.';
 				} else if (keys[i] === 'opex_val') {
 					th.textContent = 'OpEx/Base';
 					th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.';
@@ -151,7 +156,7 @@ async function render() {
 					th.textContent = 'Market Cap (Bases)';
 					th.dataset.tooltip = 'Click to sort.\n\nMarket Capacity: 7-day average traded volume ÷ daily output per base. Indicates how many full 500-area bases you can build before saturating the market.';
 				} else if (keys[i] === 'break_even') {
-					th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes working capital (days of OpEx) to accurately reflect operational readiness.';
+					th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes working capital and HQ upgrades to accurately reflect operational readiness.';
 				} else {
 					th.dataset.tooltip = 'Click to sort.';
 				}
@@ -207,11 +212,20 @@ async function render() {
 		const opex_val = p.opex[opexMetric] / bases;
 		const revenue_val = p.revenue[revenueMetric] / bases;
 		
-		// EXTREME DETAIL: CapEx now completely absorbs the working capital (days of OpEx).
-		// Because we moved the addition directly into `capex_val`, it will be explicitly rendered 
-		// inside the CapEx column on the dashboard, making the break-even math visually apparent.
 		let capex_val = (p.capex[capexMetric] / bases) + (opex_val * workingCapitalDays);
 		
+		// EXTREME DETAIL: We intercept the HQ permit cost here. 
+		// Permits 1 & 2 are free. To unlock permit 3, you must upgrade HQ to Level 2.
+		// We map the user input directly to the JSON string keys retrieved from GitHub.
+		let hq_capex = 0;
+		if (targetPermit >= 3) {
+			const hqLevelStr = (targetPermit - 1).toString();
+			if (p.hq_costs && p.hq_costs[hqLevelStr]) {
+				hq_capex = p.hq_costs[hqLevelStr][capexMetric];
+				capex_val += hq_capex;
+			}
+		}
+
 		if (includeShips) {
 			capex_val += p.ship_capex_per_base;
 		}
@@ -219,7 +233,7 @@ async function render() {
 		const profit_per_base = revenue_val - opex_val;
 		const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity;
 		
-		return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even };
+		return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even, hq_capex };
 	});
 
 	profitsWithMetrics.sort((a, b) => {
@@ -237,11 +251,8 @@ async function render() {
 	});
 
 	for (const p of profitsWithMetrics) {
-		const volumeRatio = p.output_per_day / p.average_traded_7d;
 		const tr = document.createElement('tr');
 		
-		// EXTREME DETAIL: We replaced all formatDecimal/formatWhole calls with formatSigFig.
-		// Additionally, we append the newly generated bottleneck string onto the Logistics cell.
 		tr.innerHTML = `
 			<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
 			<td>${expertise[p.expertise]}</td>
@@ -264,11 +275,14 @@ async function render() {
 			'+ worker consumables\n\n' +
 			`(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
 
-		// EXTREME DETAIL: Because working capital is now part of CapEx, we break down
-		// the constituent components in the tooltip so the math is transparent.
 		const capexCell = tr.querySelectorAll('td')[4];
 		capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(p.capex[capexMetric] / (p.area / 500))}`;
 		capexCell.dataset.tooltip += `\nWorking Capital (${workingCapitalDays} days): ${formatSigFig(p.opex_val * workingCapitalDays)}`;
+		
+		// EXTREME DETAIL: Dynamically inject the HQ cost into the tooltip if the user requested Permit 3 or higher.
+		if (p.hq_capex > 0) {
+			capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermit}): ${formatSigFig(p.hq_capex)}`;
+		}
 		if (includeShips) {
 			capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.ship_capex_per_base)} (${formatSigFig(p.ship_capex_per_base / 800_000)} ships)`;
 		}
@@ -296,6 +310,7 @@ function saveState() {
 	localStorage.setItem('roi-revenue-metric', revenueMetric);
 	localStorage.setItem('roi-include-ships', includeShips.toString());
 	localStorage.setItem('roi-working-capital', workingCapitalDays.toString());
+	localStorage.setItem('roi-target-permit', targetPermit.toString());
 }
 
 function color(n: number, low: number, high: number): string {
@@ -336,11 +351,12 @@ interface Profit {
 	input_costs: MatPrice[]
 	runs_per_day: number
 	logistics_per_base: number
-	logistics_bottleneck: string // Extracted from backend
+	logistics_bottleneck: string 
 	output_per_day: number
 	average_traded_7d: number
 	market_capacity_base: number
 	ship_capex_per_base: number
+	hq_costs: Record<string, Metrics> // Added typing for the precalculated HQ pricing dictionary
 }
 
 interface ProfitWithMetrics extends Profit {
@@ -350,6 +366,7 @@ interface ProfitWithMetrics extends Profit {
 	profit_per_day: number;
 	profit_per_base: number;
 	break_even: number;
+	hq_capex: number; // Added to interface to pass to the tooltip generator
 }
 
 interface MatPrice {

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 299 - 283
www/roi_ai1.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 365 - 291
www/roi_ci1.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 290 - 273
www/roi_ic1.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 224 - 206
www/roi_nc1.json


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است