Преглед изворни кода

Add_Fleet_Logistics_Capital_Expenditure

Integrated fractional ship procurement costs into base CapEx modeling.

        PURPOSE:
        To provide a brutally realistic timeline for return on investment (ROI). Previously, scaling up to a 500-area base modeled the raw construction costs of the factories and habitats, but entirely ignored the capital required to transport the massive influx of inputs and outputs. By calculating the exact shipping capacity bottleneck per day and translating that into the cost of 800k-credit freighters, users can now toggle "Include Ship CapEx" to see their true operational break-even time.

        IMPLEMENTATION DETAILS:
        - `roi.py`: Refactored `logistics_per_base` calculations to extract `in_w`, `in_v`, `out_w`, and `out_v` into explicit variables.
        - `roi.py`: Calculated `ships_needed_per_base` by finding the maximum bottleneck across the round trip using standard freighter limits (3000 weight OR 1000 volume) and scaling by `runs_per_base`. Multiplied the output by 800,000 to derive `ship_capex_per_base`.
        - `ts/roi.ts`: Injected a new `<input type="checkbox" id="include-ships">` into the DOM alongside the price metric dropdowns, binding it to a persistent `localStorage` state variable.
        - `ts/roi.ts`: Inside the dynamic mapping loop, conditionally added `p.ship_capex_per_base` to `capex_val` when the toggle is active. Because this execution occurs *before* `break_even` is computed, the new cost cascades perfectly into the ROI math.
        - `ts/roi.ts`: Overrode the CapEx cell's empty tooltip to explicitly print a breakdown of `Base Construction` vs `Ship CapEx`, demonstrating exactly how many fractional ships were mathematically requested for the user's specific recipe pipeline.
Thomas Knott пре 2 недеља
родитељ
комит
81259ae3d5
2 измењених фајлова са 49 додато и 21 уклоњено
  1. 18 9
      roi.py
  2. 31 12
      ts/roi.ts

+ 18 - 9
roi.py

@@ -103,12 +103,21 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 	output_per_base = output_per_day / (area / 500)
 	market_capacity_base = average_traded_7d / output_per_base
 
-	logistics_per_base = max(
-		sum(materials[input['material_ticker']]['weight'] * input['material_amount'] for input in recipe['inputs']),
-		sum(materials[input['material_ticker']]['volume'] * input['material_amount'] for input in recipe['inputs']),
-		sum(materials[output['material_ticker']]['weight'] * output['material_amount'] for output in recipe['outputs']),
-		sum(materials[output['material_ticker']]['volume'] * output['material_amount'] for output in recipe['outputs']),
-	) * runs_per_day / (area / 500)
+	# EXTREME DETAIL: Abstracted the 4 core logistics metrics into explicit variables for readability.
+	in_w = sum(materials[input['material_ticker']]['weight'] * input['material_amount'] for input in recipe['inputs'])
+	in_v = sum(materials[input['material_ticker']]['volume'] * input['material_amount'] for input in recipe['inputs'])
+	out_w = sum(materials[output['material_ticker']]['weight'] * output['material_amount'] for output in recipe['outputs'])
+	out_v = sum(materials[output['material_ticker']]['volume'] * output['material_amount'] for output in recipe['outputs'])
+
+	runs_per_base = runs_per_day / (area / 500)
+	
+	# EXTREME DETAIL: We compute the fraction of ship cargo space required per base per day.
+	# We evaluate the maximum bottleneck across the round trip (assuming 3000 weight OR 1000 volume limits).
+	# This ship fraction is then multiplied by 800,000 (the capital cost of a single ship).
+	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
+
+	logistics_per_base = max(in_w, in_v, out_w, out_v) * runs_per_base
 	
 	return Profit(outputs, recipe['recipe_name'],
 			expertise=building['expertise'],
@@ -122,7 +131,8 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 			logistics_per_base=logistics_per_base,
 			output_per_day=output_per_day,
 			average_traded_7d=average_traded_7d,
-			market_capacity_base=market_capacity_base)
+			market_capacity_base=market_capacity_base,
+			ship_capex_per_base=ship_capex_per_base)
 
 def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
 	cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
@@ -221,10 +231,9 @@ class Profit:
 	output_per_day: float
 	average_traded_7d: float
 	market_capacity_base: float
+	ship_capex_per_base: float # Added pre-calculated property tracking
 
 	def __lt__(self, other: Profit) -> bool:
-		# EXTREME DETAIL: Apply identical `(area / 500)` base scaling to the backend's
-		# default VWAP sorting algorithm so it perfectly mirrors the TS frontend implementation.
 		bases_a = self.area / 500
 		p_a = (self.revenue['vwap'] - self.opex['vwap']) / bases_a
 		c_a = self.capex['vwap'] / bases_a

+ 31 - 12
ts/roi.ts

@@ -51,6 +51,7 @@ let metricControlsInitialized = false;
 let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as MetricType) || 'vwap';
 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';
 
 async function render() {
 	const tbody = document.querySelector('tbody')!;
@@ -64,6 +65,7 @@ async function render() {
 	if (!metricControlsInitialized) {
 		const controls = document.createElement('div');
 		controls.style.marginBottom = '15px';
+		// EXTREME DETAIL: Added a new checkbox for 'Include Ship CapEx' directly alongside the dropdowns.
 		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>
@@ -71,9 +73,12 @@ async function render() {
 			<label style="margin-right: 15px;">OpEx Price: 
 				<select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
 			</label>
-			<label>Revenue (Outputs) Price: 
+			<label style="margin-right: 15px;">Revenue Price: 
 				<select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
 			</label>
+			<label>
+				<input type="checkbox" id="include-ships"> Include Ship CapEx
+			</label>
 		`;
 		const table = document.querySelector('table');
 		if (table) table.parentNode?.insertBefore(controls, table);
@@ -81,6 +86,7 @@ async function render() {
 		(document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
 		(document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
 		(document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
+		(document.getElementById('include-ships') as HTMLInputElement).checked = includeShips;
 
 		document.getElementById('capex-metric')!.addEventListener('change', (e) => {
 			capexMetric = (e.target as HTMLSelectElement).value as MetricType;
@@ -94,6 +100,10 @@ async function render() {
 			revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
 			render();
 		});
+		document.getElementById('include-ships')!.addEventListener('change', (e) => {
+			includeShips = (e.target as HTMLInputElement).checked;
+			render();
+		});
 		metricControlsInitialized = true;
 	}
 
@@ -114,7 +124,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 (construction + habitation costs) scaled to a full 500-area planetary base.';
+					th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure (construction + habitation costs) scaled to a full 500-area planetary base.\nIf "Include Ship CapEx" is toggled, adds the cost of ships (800k each) required for daily logistics.';
 				} 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.';
@@ -177,12 +187,18 @@ async function render() {
 	});
 
 	const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
-		// EXTREME DETAIL: Apply the 500-area baseline scaling factor to all financial metrics.
 		const bases = p.area / 500;
-		const capex_val = p.capex[capexMetric] / bases;
+		let capex_val = p.capex[capexMetric] / bases;
 		const opex_val = p.opex[opexMetric] / bases;
 		const revenue_val = p.revenue[revenueMetric] / bases;
 		
+		// EXTREME DETAIL: If the user toggles the UI checkbox, we silently inject the precalculated
+		// ship capital expenditures into the active CapEx pipeline. This causes the break-even math
+		// to dynamically cascade and update instantly.
+		if (includeShips) {
+			capex_val += p.ship_capex_per_base;
+		}
+
 		const profit_per_base = revenue_val - opex_val;
 		const break_even = profit_per_base > 0 ? (capex_val + 3 * opex_val) / profit_per_base : Infinity;
 		
@@ -207,10 +223,6 @@ async function render() {
 		const volumeRatio = p.output_per_day / p.average_traded_7d;
 		const tr = document.createElement('tr');
 		
-		// EXTREME DETAIL: Because 1 base = 500 area, the color scales must be adjusted 
-		// proportionately (roughly ~10x depending on the building) to ensure the visual gradients still 
-		// render accurately for the expanded scale.
-		// e.g. CapEx bounds changed from 300,000 to 3,000,000. 
 		tr.innerHTML = `
 			<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
 			<td>${expertise[p.expertise]}</td>
@@ -226,10 +238,6 @@ async function render() {
 		output.dataset.tooltip = p.recipe;
 
 		const profitCell = tr.querySelectorAll('td')[2];
-		
-		// EXTREME DETAIL: Pass the per-base scaling factor into the formatMatPrices tooltip function.
-		// This explicitly scales the quantities of inputs and outputs shown in the hover breakdown 
-		// so that the math perfectly matches the final Profit/Base number shown in the table.
 		const runs_per_base = p.runs_per_day / (p.area / 500);
 		
 		profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
@@ -237,6 +245,15 @@ async function render() {
 			'+ worker consumables\n\n' +
 			`(${formatWhole(p.revenue_val)} - ${formatWhole(p.opex_val)}) = ${formatDecimal(p.profit_per_base)}`;
 
+		// EXTREME DETAIL: To provide absolute transparency, if the ship toggle is active,
+		// we inject a dedicated tooltip into the CapEx cell specifically. This proves to the user 
+		// exactly how many fractional ships were assumed and how much they cost.
+		const capexCell = tr.querySelectorAll('td')[4];
+		capexCell.dataset.tooltip = `Base Construction: ${formatWhole(p.capex[capexMetric] / (p.area / 500))}`;
+		if (includeShips) {
+			capexCell.dataset.tooltip += `\nShip CapEx: ${formatWhole(p.ship_capex_per_base)} (${formatDecimal(p.ship_capex_per_base / 800_000)} ships)`;
+		}
+
 		const marketCell = tr.querySelectorAll('td')[7];
 		marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / (p.area / 500))} produced/day/base = ${formatDecimal(p.market_capacity_base)} equivalent bases`;
 
@@ -258,6 +275,7 @@ function saveState() {
 	localStorage.setItem('roi-capex-metric', capexMetric);
 	localStorage.setItem('roi-opex-metric', opexMetric);
 	localStorage.setItem('roi-revenue-metric', revenueMetric);
+	localStorage.setItem('roi-include-ships', includeShips.toString());
 }
 
 function color(n: number, low: number, high: number): string {
@@ -301,6 +319,7 @@ interface Profit {
 	output_per_day: number
 	average_traded_7d: number
 	market_capacity_base: number
+	ship_capex_per_base: number
 }
 
 interface ProfitWithMetrics extends Profit {