From 016c73bc6bd7b87fd917a1f8d0e25b02f18c74a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Cancio=20V=C3=A1zquez?= Date: Wed, 21 Jan 2026 11:52:42 +0100 Subject: [PATCH 1/6] feat(government): add upkeep price calculator tool --- .../help/tools_upkeep_price_calculator.md | 12 + .../components/UpkeepPriceCalculator.vue | 194 ++++++++++++ src/features/government/upkeepCalculations.ts | 292 ++++++++++++++++++ .../government/upkeepCalculations.types.d.ts | 35 +++ src/layout/components/NavigationBar.vue | 7 + src/router/index.ts | 7 + src/views/tools/UpkeepPriceCalculatorView.vue | 58 ++++ 7 files changed, 605 insertions(+) create mode 100644 src/assets/help/tools_upkeep_price_calculator.md create mode 100644 src/features/government/components/UpkeepPriceCalculator.vue create mode 100644 src/features/government/upkeepCalculations.ts create mode 100644 src/features/government/upkeepCalculations.types.d.ts create mode 100644 src/views/tools/UpkeepPriceCalculatorView.vue diff --git a/src/assets/help/tools_upkeep_price_calculator.md b/src/assets/help/tools_upkeep_price_calculator.md new file mode 100644 index 00000000..980c24c2 --- /dev/null +++ b/src/assets/help/tools_upkeep_price_calculator.md @@ -0,0 +1,12 @@ +The Upkeep Price Calculator helps you find the most cost-effective materials to fulfill population upkeep needs (Safety, Health, Comfort, Culture, and Education) on governed planets. + +For each need category, the tool displays all materials consumed by the corresponding infrastructure buildings, sorted by their cost efficiency ($/Need). This allows you to quickly identify which materials provide the best value when supplying planetary infrastructure. + +**Key columns explained:** + +- **$/Need**: The cost per unit of need provided. Lower is better. Calculated as: `CX Price / (Need Provided / Qty per Day)`. +- **CX Price**: The current market price based on your exchange preferences. +- **Relative Price**: The price a material would need to have to match the efficiency of the best option. If a material's CX Price drops below its Relative Price, it becomes more efficient than the current best choice. +- **Qty/Day**: The quantity of material consumed per day by the building. + +The tool considers all infrastructure buildings that provide each need type, including buildings that provide multiple needs (like EMC for Safety and Health, or WCE for Health and Comfort). diff --git a/src/features/government/components/UpkeepPriceCalculator.vue b/src/features/government/components/UpkeepPriceCalculator.vue new file mode 100644 index 00000000..f34d9275 --- /dev/null +++ b/src/features/government/components/UpkeepPriceCalculator.vue @@ -0,0 +1,194 @@ + + + diff --git a/src/features/government/upkeepCalculations.ts b/src/features/government/upkeepCalculations.ts new file mode 100644 index 00000000..64a8cd13 --- /dev/null +++ b/src/features/government/upkeepCalculations.ts @@ -0,0 +1,292 @@ +// Types & Interfaces +import { + IUpkeepBuilding, + IUpkeepMaterialCalculation, + UpkeepNeedType, +} from "@/features/government/upkeepCalculations.types"; + +export const UPKEEP_NEED_TYPES: UpkeepNeedType[] = [ + "safety", + "health", + "comfort", + "culture", + "education", +]; + +export const UPKEEP_BUILDINGS: IUpkeepBuilding[] = [ + { + ticker: "SST", + materials: [ + { ticker: "DW", qtyPerDay: 10 }, + { ticker: "OFF", qtyPerDay: 10 }, + { ticker: "SUN", qtyPerDay: 2 }, + ], + needs: { safety: 833.3, health: 0, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "SDP", + materials: [ + { ticker: "POW", qtyPerDay: 1 }, + { ticker: "RAD", qtyPerDay: 0.47 }, + { ticker: "CCD", qtyPerDay: 0.07 }, + { ticker: "SUD", qtyPerDay: 0.07 }, + ], + needs: { safety: 1250, health: 0, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "EMC", + materials: [ + { ticker: "PK", qtyPerDay: 2 }, + { ticker: "POW", qtyPerDay: 0.4 }, + { ticker: "BND", qtyPerDay: 4 }, + { ticker: "RED", qtyPerDay: 0.07 }, + { ticker: "BSC", qtyPerDay: 0.07 }, + ], + needs: { safety: 200, health: 200, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "INF", + materials: [ + { ticker: "OFF", qtyPerDay: 10 }, + { ticker: "TUB", qtyPerDay: 6.67 }, + { ticker: "STR", qtyPerDay: 0.67 }, + ], + needs: { safety: 0, health: 833.33, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "HOS", + materials: [ + { ticker: "PK", qtyPerDay: 2 }, + { ticker: "SEQ", qtyPerDay: 0.4 }, + { ticker: "BND", qtyPerDay: 4 }, + { ticker: "SDR", qtyPerDay: 0.07 }, + { ticker: "RED", qtyPerDay: 0.07 }, + { ticker: "BSC", qtyPerDay: 0.13 }, + ], + needs: { safety: 0, health: 833.33, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "WCE", + materials: [ + { ticker: "KOM", qtyPerDay: 4 }, + { ticker: "OLF", qtyPerDay: 2 }, + { ticker: "DW", qtyPerDay: 6 }, + { ticker: "DEC", qtyPerDay: 0.67 }, + { ticker: "PFE", qtyPerDay: 2.67 }, + { ticker: "SOI", qtyPerDay: 6.67 }, + ], + needs: { safety: 0, health: 166.67, comfort: 166.7, culture: 0, education: 0 }, + }, + { + ticker: "PAR", + materials: [ + { ticker: "DW", qtyPerDay: 10 }, + { ticker: "FOD", qtyPerDay: 6 }, + { ticker: "PFE", qtyPerDay: 2 }, + { ticker: "SOI", qtyPerDay: 3.33 }, + { ticker: "DEC", qtyPerDay: 0.33 }, + ], + needs: { safety: 0, health: 0, comfort: 500, culture: 0, education: 0 }, + }, + { + ticker: "4DA", + materials: [ + { ticker: "POW", qtyPerDay: 2 }, + { ticker: "MHP", qtyPerDay: 2 }, + { ticker: "OLF", qtyPerDay: 4 }, + { ticker: "BID", qtyPerDay: 0.2 }, + { ticker: "HOG", qtyPerDay: 0.2 }, + { ticker: "EDC", qtyPerDay: 0.2 }, + ], + needs: { safety: 0, health: 0, comfort: 833.3, culture: 0, education: 0 }, + }, + { + ticker: "ACA", + materials: [ + { ticker: "COF", qtyPerDay: 8 }, + { ticker: "OLF", qtyPerDay: 2 }, + { ticker: "VIT", qtyPerDay: 8 }, + { ticker: "DW", qtyPerDay: 10 }, + { ticker: "GL", qtyPerDay: 6.67 }, + { ticker: "DEC", qtyPerDay: 0.67 }, + ], + needs: { safety: 0, health: 0, comfort: 166.7, culture: 166.7, education: 0 }, + }, + { + ticker: "ART", + materials: [ + { ticker: "MHP", qtyPerDay: 1 }, + { ticker: "HOG", qtyPerDay: 1 }, + { ticker: "UTS", qtyPerDay: 0.67 }, + { ticker: "DEC", qtyPerDay: 0.67 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 625, education: 0 }, + }, + { + ticker: "VRT", + materials: [ + { ticker: "POW", qtyPerDay: 1.4 }, + { ticker: "MHP", qtyPerDay: 2 }, + { ticker: "HOG", qtyPerDay: 1.4 }, + { ticker: "OLF", qtyPerDay: 4 }, + { ticker: "BID", qtyPerDay: 0.33 }, + { ticker: "DEC", qtyPerDay: 0.67 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 833.3, education: 0 }, + }, + { + ticker: "PBH", + materials: [ + { ticker: "OFF", qtyPerDay: 10 }, + { ticker: "MHP", qtyPerDay: 1 }, + { ticker: "SP", qtyPerDay: 1.33 }, + { ticker: "AAR", qtyPerDay: 0.67 }, + { ticker: "EDC", qtyPerDay: 0.27 }, + { ticker: "IDC", qtyPerDay: 0.13 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 166.7, education: 166.7 }, + }, + { + ticker: "LIB", + materials: [ + { ticker: "MHP", qtyPerDay: 1 }, + { ticker: "HOG", qtyPerDay: 1 }, + { ticker: "CD", qtyPerDay: 0.33 }, + { ticker: "DIS", qtyPerDay: 0.33 }, + { ticker: "BID", qtyPerDay: 0.2 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 0, education: 500 }, + }, + { + ticker: "UNI", + materials: [ + { ticker: "COF", qtyPerDay: 10 }, + { ticker: "REA", qtyPerDay: 10 }, + { ticker: "TUB", qtyPerDay: 10 }, + { ticker: "BID", qtyPerDay: 0.33 }, + { ticker: "HD", qtyPerDay: 0.67 }, + { ticker: "IDC", qtyPerDay: 0.2 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 0, education: 833.3 }, + }, +]; + +/** + * Calculates the price per need unit for a material + * @param materialPrice The CX price of the material + * @param qtyPerDay The quantity consumed per day + * @param needProvided The amount of need provided by the building + * @returns Price per need unit + */ +export function calculatePricePerNeed( + materialPrice: number, + qtyPerDay: number, + needProvided: number +): number { + if (needProvided === 0 || qtyPerDay === 0) return Infinity; + const needPerQty = needProvided / qtyPerDay; + return materialPrice / needPerQty; +} + +/** + * Calculates the relative price compared to the lowest price per need + * @param materialPrice The CX price of the material + * @param pricePerNeed The price per need for this material + * @param lowestPricePerNeed The lowest price per need (anchor) + * @returns Relative price + */ +export function calculateRelativePrice( + materialPrice: number, + pricePerNeed: number, + lowestPricePerNeed: number +): number { + if (pricePerNeed === 0 || pricePerNeed === Infinity) return materialPrice; + return (materialPrice / pricePerNeed) * lowestPricePerNeed; +} + +/** + * Gets all buildings that provide a specific need type + * @param needType The type of need + * @returns Array of buildings providing that need + */ +export function getBuildingsForNeed(needType: UpkeepNeedType): IUpkeepBuilding[] { + return UPKEEP_BUILDINGS.filter((building) => building.needs[needType] > 0); +} + +/** + * Counts how many need types a building provides + * Used to split value equally among needs for multi-need buildings + * @param building The building to check + * @returns Number of need types provided (1 or 2) + */ +export function getBuildingNeedCount(building: IUpkeepBuilding): number { + return UPKEEP_NEED_TYPES.filter((needType) => building.needs[needType] > 0) + .length; +} + +/** + * Calculates material prices for a specific need type + * @param needType The type of need + * @param getPriceFunc Function to get material prices + * @returns Array of material calculations sorted by price per need + */ +export async function calculateMaterialsForNeed( + needType: UpkeepNeedType, + getPriceFunc: (ticker: string) => Promise +): Promise { + const buildings = getBuildingsForNeed(needType); + const calculations: IUpkeepMaterialCalculation[] = []; + + for (const building of buildings) { + const needProvided = building.needs[needType]; + // For buildings with multiple needs, multiply by need count + // since the same materials provide value for all needs + const needCount = getBuildingNeedCount(building); + const effectiveNeed = needProvided * needCount; + + for (const material of building.materials) { + const cxPrice = await getPriceFunc(material.ticker); + const pricePerNeed = calculatePricePerNeed( + cxPrice, + material.qtyPerDay, + effectiveNeed + ); + + calculations.push({ + ticker: material.ticker, + buildingTicker: building.ticker, + qtyPerDay: material.qtyPerDay, + needProvided, + pricePerNeed, + cxPrice, + relativePrice: 0, // Will be calculated after sorting + }); + } + } + + // Sort by price per need ascending, but put items with no price (0) at the end + calculations.sort((a, b) => { + if (a.cxPrice <= 0 && b.cxPrice <= 0) return 0; + if (a.cxPrice <= 0) return 1; + if (b.cxPrice <= 0) return -1; + return a.pricePerNeed - b.pricePerNeed; + }); + + // Calculate relative prices using the lowest PRICED material as anchor + const pricedCalculations = calculations.filter((c) => c.cxPrice > 0); + if (pricedCalculations.length > 0) { + const lowestPricePerNeed = pricedCalculations[0].pricePerNeed; + for (const calc of calculations) { + if (calc.cxPrice > 0) { + calc.relativePrice = calculateRelativePrice( + calc.cxPrice, + calc.pricePerNeed, + lowestPricePerNeed + ); + } + // Materials with no price keep relativePrice = 0 + } + } + + return calculations; +} diff --git a/src/features/government/upkeepCalculations.types.d.ts b/src/features/government/upkeepCalculations.types.d.ts new file mode 100644 index 00000000..f6555f93 --- /dev/null +++ b/src/features/government/upkeepCalculations.types.d.ts @@ -0,0 +1,35 @@ +export type UpkeepNeedType = + | "safety" + | "health" + | "comfort" + | "culture" + | "education"; + +export interface IUpkeepMaterial { + ticker: string; + qtyPerDay: number; +} + +export interface IUpkeepNeeds { + safety: number; + health: number; + comfort: number; + culture: number; + education: number; +} + +export interface IUpkeepBuilding { + ticker: string; + materials: IUpkeepMaterial[]; + needs: IUpkeepNeeds; +} + +export interface IUpkeepMaterialCalculation { + ticker: string; + buildingTicker: string; + qtyPerDay: number; + needProvided: number; + pricePerNeed: number; + cxPrice: number; + relativePrice: number; +} diff --git a/src/layout/components/NavigationBar.vue b/src/layout/components/NavigationBar.vue index 6457cc40..9681c915 100644 --- a/src/layout/components/NavigationBar.vue +++ b/src/layout/components/NavigationBar.vue @@ -43,6 +43,7 @@ TravelExploreSharp, KeyboardDoubleArrowLeftSharp, KeyboardDoubleArrowRightSharp, + AccountBalanceSharp, } from "@vicons/material"; const userStore = useUserStore(); @@ -191,6 +192,12 @@ routerLink: "/production-chain", icon: CompareSharp, }, + { + label: "Upkeep Prices", + display: true, + routerLink: "/upkeep-price-calculator", + icon: AccountBalanceSharp, + }, // { // label: "Base Compare", // display: true, diff --git a/src/router/index.ts b/src/router/index.ts index 22ac43b6..71996652 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -124,6 +124,13 @@ const router = createRouter({ meta: { requiresAuth: true }, component: () => import("@/views/tools/ProductionChainView.vue"), }, + { + name: "upkeep-price-calculator", + path: "/upkeep-price-calculator", + meta: { requiresAuth: true }, + component: () => + import("@/views/tools/UpkeepPriceCalculatorView.vue"), + }, { name: "verify-email", path: "/verify-email", diff --git a/src/views/tools/UpkeepPriceCalculatorView.vue b/src/views/tools/UpkeepPriceCalculatorView.vue new file mode 100644 index 00000000..b44953a8 --- /dev/null +++ b/src/views/tools/UpkeepPriceCalculatorView.vue @@ -0,0 +1,58 @@ + + + From 24a4c28ee9bf7b5527a890f9e43abb55834e9e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Cancio=20V=C3=A1zquez?= Date: Wed, 21 Jan 2026 12:13:59 +0100 Subject: [PATCH 2/6] refactor(government): modularize upkeep calculator with composables and add building summary --- .../components/UpkeepPriceCalculator.vue | 306 ++++++++++++------ .../composables/useUpkeepBuildingSummary.ts | 106 ++++++ .../composables/useUpkeepBuildings.ts | 64 ++++ .../composables/useUpkeepPriceCalculator.ts | 152 +++++++++ .../government/upkeepCalculations.types.d.ts | 10 + 5 files changed, 534 insertions(+), 104 deletions(-) create mode 100644 src/features/government/composables/useUpkeepBuildingSummary.ts create mode 100644 src/features/government/composables/useUpkeepBuildings.ts create mode 100644 src/features/government/composables/useUpkeepPriceCalculator.ts diff --git a/src/features/government/components/UpkeepPriceCalculator.vue b/src/features/government/components/UpkeepPriceCalculator.vue index f34d9275..be21222a 100644 --- a/src/features/government/components/UpkeepPriceCalculator.vue +++ b/src/features/government/components/UpkeepPriceCalculator.vue @@ -2,13 +2,9 @@ import { ComputedRef, Ref, computed, onMounted, ref, watch } from "vue"; // Composables - import { usePrice } from "@/features/cx/usePrice"; - - // Calculations - import { - calculateMaterialsForNeed, - UPKEEP_NEED_TYPES, - } from "@/features/government/upkeepCalculations"; + import { useUpkeepBuildings } from "@/features/government/composables/useUpkeepBuildings"; + import { useUpkeepPriceCalculator } from "@/features/government/composables/useUpkeepPriceCalculator"; + import { useUpkeepBuildingSummary } from "@/features/government/composables/useUpkeepBuildingSummary"; // Util import { formatNumber } from "@/util/numbers"; @@ -20,11 +16,12 @@ // Types & Interfaces import { IUpkeepMaterialCalculation, + IUpkeepBuildingSummary, UpkeepNeedType, } from "@/features/government/upkeepCalculations.types"; // UI - import { PProgressBar, PButton, PButtonGroup } from "@/ui"; + import { PProgressBar, PButton, PButtonGroup, PTag, PTooltip } from "@/ui"; import { XNDataTable, XNDataTableColumn } from "@skit/x.naive-ui"; const props = defineProps({ @@ -38,6 +35,11 @@ const cxUuid: ComputedRef = computed(() => props.cxUuid); const planetNaturalId: Ref = ref(undefined); + // Composables + const { needTypes } = useUpkeepBuildings(); + const { calculateAllBuildingSummaries } = useUpkeepBuildingSummary(); + + // State const isCalculating: Ref = ref(true); const selectedNeedType: Ref = ref("safety"); const calculationResults: Ref< @@ -50,21 +52,27 @@ education: [], }); + // Computed const currentResults: ComputedRef = computed( () => calculationResults.value[selectedNeedType.value] ); + const buildingSummaries: ComputedRef = computed( + () => + calculateAllBuildingSummaries( + selectedNeedType.value, + currentResults.value + ) + ); + async function calculate() { isCalculating.value = true; - const { getPrice } = await usePrice(cxUuid, planetNaturalId); - - for (const needType of UPKEEP_NEED_TYPES) { - calculationResults.value[needType] = await calculateMaterialsForNeed( - needType, - (ticker: string) => getPrice(ticker, "BUY") - ); - } + const { calculateAllNeeds } = await useUpkeepPriceCalculator( + cxUuid, + planetNaturalId + ); + calculationResults.value = await calculateAllNeeds(); isCalculating.value = false; } @@ -91,10 +99,11 @@
+
@@ -103,92 +112,181 @@
- - - - - - - - - - - - - - - - - - - - - - - - + +
+

+ Building Summary +

+ + + + + + + + + + + + + + + + + +
+ + +
+

+ Material Details +

+ + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/features/government/composables/useUpkeepBuildingSummary.ts b/src/features/government/composables/useUpkeepBuildingSummary.ts new file mode 100644 index 00000000..873948b1 --- /dev/null +++ b/src/features/government/composables/useUpkeepBuildingSummary.ts @@ -0,0 +1,106 @@ +// Composables +import { useUpkeepBuildings } from "./useUpkeepBuildings"; + +// Types & Interfaces +import { + IUpkeepMaterialCalculation, + IUpkeepBuildingSummary, + UpkeepNeedType, +} from "@/features/government/upkeepCalculations.types"; + +/** + * Composable for aggregating upkeep data at building level + * Reusable by: building comparison, summary views, infrastructure planner + */ +export function useUpkeepBuildingSummary() { + const { getBuildingsForNeed, getBuildingByTicker } = useUpkeepBuildings(); + + /** + * Calculates summary for a single building from its material calculations + * @param buildingTicker The building ticker + * @param materialCalculations All material calculations (will be filtered) + * @returns Building summary with aggregated costs and availability + */ + function calculateBuildingSummary( + buildingTicker: string, + materialCalculations: IUpkeepMaterialCalculation[] + ): IUpkeepBuildingSummary | undefined { + const building = getBuildingByTicker(buildingTicker); + if (!building) return undefined; + + // Filter calculations for this building + const buildingMaterials = materialCalculations.filter( + (calc) => calc.buildingTicker === buildingTicker + ); + + if (buildingMaterials.length === 0) return undefined; + + // Calculate aggregates + const materialsWithPrice = buildingMaterials.filter( + (calc) => calc.cxPrice > 0 + ); + const totalPricePerNeed = materialsWithPrice.reduce( + (sum, calc) => sum + calc.pricePerNeed, + 0 + ); + const totalDailyCost = materialsWithPrice.reduce( + (sum, calc) => sum + calc.cxPrice * calc.qtyPerDay, + 0 + ); + + // Get need provided (from the first material, they all have the same) + const needProvided = buildingMaterials[0].needProvided; + + return { + ticker: buildingTicker, + totalPricePerNeed, + totalDailyCost, + materialsAvailable: materialsWithPrice.length, + materialsTotal: building.materials.length, + needProvided, + isComplete: materialsWithPrice.length === building.materials.length, + }; + } + + /** + * Calculates summaries for all buildings providing a specific need + * @param needType The need type to calculate summaries for + * @param materialCalculations All material calculations for this need type + * @returns Array of building summaries sorted by total price per need + */ + function calculateAllBuildingSummaries( + needType: UpkeepNeedType, + materialCalculations: IUpkeepMaterialCalculation[] + ): IUpkeepBuildingSummary[] { + const buildings = getBuildingsForNeed(needType); + const summaries: IUpkeepBuildingSummary[] = []; + + for (const building of buildings) { + const summary = calculateBuildingSummary( + building.ticker, + materialCalculations + ); + if (summary) { + summaries.push(summary); + } + } + + // Sort by total price per need ascending + // Put incomplete buildings (missing material prices) at the end + summaries.sort((a, b) => { + if (!a.isComplete && !b.isComplete) { + return a.totalPricePerNeed - b.totalPricePerNeed; + } + if (!a.isComplete) return 1; + if (!b.isComplete) return -1; + return a.totalPricePerNeed - b.totalPricePerNeed; + }); + + return summaries; + } + + return { + calculateBuildingSummary, + calculateAllBuildingSummaries, + }; +} diff --git a/src/features/government/composables/useUpkeepBuildings.ts b/src/features/government/composables/useUpkeepBuildings.ts new file mode 100644 index 00000000..590225f7 --- /dev/null +++ b/src/features/government/composables/useUpkeepBuildings.ts @@ -0,0 +1,64 @@ +// Types & Interfaces +import { + IUpkeepBuilding, + UpkeepNeedType, +} from "@/features/government/upkeepCalculations.types"; +import { + UPKEEP_BUILDINGS, + UPKEEP_NEED_TYPES, +} from "@/features/government/upkeepCalculations"; + +/** + * Composable for accessing and filtering upkeep building data + * Reusable by: price calculator, infrastructure planner, budget simulator + */ +export function useUpkeepBuildings() { + const buildings: IUpkeepBuilding[] = UPKEEP_BUILDINGS; + const needTypes: UpkeepNeedType[] = UPKEEP_NEED_TYPES; + + /** + * Gets all buildings that provide a specific need type + * @param needType The type of need + * @returns Array of buildings providing that need + */ + function getBuildingsForNeed(needType: UpkeepNeedType): IUpkeepBuilding[] { + return buildings.filter((building) => building.needs[needType] > 0); + } + + /** + * Gets a building by its ticker + * @param ticker The building ticker (e.g., "SST", "HOS") + * @returns The building or undefined if not found + */ + function getBuildingByTicker(ticker: string): IUpkeepBuilding | undefined { + return buildings.find((building) => building.ticker === ticker); + } + + /** + * Counts how many need types a building provides + * Used to split value equally among needs for multi-need buildings + * @param building The building to check + * @returns Number of need types provided (1 or 2) + */ + function getBuildingNeedCount(building: IUpkeepBuilding): number { + return needTypes.filter((needType) => building.needs[needType] > 0).length; + } + + /** + * Gets all need types that a building provides + * @param building The building to check + * @returns Array of need types provided + */ + function getBuildingNeedTypes(building: IUpkeepBuilding): UpkeepNeedType[] { + return needTypes.filter((needType) => building.needs[needType] > 0); + } + + return { + buildings, + needTypes, + getBuildingsForNeed, + getBuildingByTicker, + getBuildingNeedCount, + getBuildingNeedTypes, + }; +} diff --git a/src/features/government/composables/useUpkeepPriceCalculator.ts b/src/features/government/composables/useUpkeepPriceCalculator.ts new file mode 100644 index 00000000..7db90c0a --- /dev/null +++ b/src/features/government/composables/useUpkeepPriceCalculator.ts @@ -0,0 +1,152 @@ +import { Ref, ref, ComputedRef, computed } from "vue"; + +// Composables +import { usePrice } from "@/features/cx/usePrice"; +import { useUpkeepBuildings } from "./useUpkeepBuildings"; + +// Calculations +import { + calculatePricePerNeed, + calculateRelativePrice, +} from "@/features/government/upkeepCalculations"; + +// Types & Interfaces +import { + IUpkeepMaterialCalculation, + UpkeepNeedType, +} from "@/features/government/upkeepCalculations.types"; + +/** + * Composable for calculating upkeep material prices + * Reusable by: price calculator view, cost analysis, building comparison + */ +export async function useUpkeepPriceCalculator( + cxUuid: Ref, + planetNaturalId: Ref = ref(undefined) +) { + const { getPrice } = await usePrice(cxUuid, planetNaturalId); + const { getBuildingsForNeed, getBuildingNeedCount, needTypes } = + useUpkeepBuildings(); + + const isCalculating: Ref = ref(false); + const calculationResults: Ref< + Record + > = ref({ + safety: [], + health: [], + comfort: [], + culture: [], + education: [], + }); + + /** + * Calculates material prices for a specific need type + * @param needType The type of need to calculate + * @returns Array of material calculations sorted by price per need + */ + async function calculateMaterialsForNeed( + needType: UpkeepNeedType + ): Promise { + const buildings = getBuildingsForNeed(needType); + const calculations: IUpkeepMaterialCalculation[] = []; + + for (const building of buildings) { + const needProvided = building.needs[needType]; + // For buildings with multiple needs, multiply by need count + // since the same materials provide value for all needs + const needCount = getBuildingNeedCount(building); + const effectiveNeed = needProvided * needCount; + + for (const material of building.materials) { + const cxPrice = await getPrice(material.ticker, "BUY"); + const pricePerNeed = calculatePricePerNeed( + cxPrice, + material.qtyPerDay, + effectiveNeed + ); + + calculations.push({ + ticker: material.ticker, + buildingTicker: building.ticker, + qtyPerDay: material.qtyPerDay, + needProvided, + pricePerNeed, + cxPrice, + relativePrice: 0, // Will be calculated after sorting + }); + } + } + + // Sort by price per need ascending, but put items with no price (0) at the end + calculations.sort((a, b) => { + if (a.cxPrice <= 0 && b.cxPrice <= 0) return 0; + if (a.cxPrice <= 0) return 1; + if (b.cxPrice <= 0) return -1; + return a.pricePerNeed - b.pricePerNeed; + }); + + // Calculate relative prices using the lowest PRICED material as anchor + const pricedCalculations = calculations.filter((c) => c.cxPrice > 0); + if (pricedCalculations.length > 0) { + const lowestPricePerNeed = pricedCalculations[0].pricePerNeed; + for (const calc of calculations) { + if (calc.cxPrice > 0) { + calc.relativePrice = calculateRelativePrice( + calc.cxPrice, + calc.pricePerNeed, + lowestPricePerNeed + ); + } + // Materials with no price keep relativePrice = 0 + } + } + + return calculations; + } + + /** + * Calculates material prices for all need types + * @returns Record of all need types with their material calculations + */ + async function calculateAllNeeds(): Promise< + Record + > { + isCalculating.value = true; + + const results: Record = { + safety: [], + health: [], + comfort: [], + culture: [], + education: [], + }; + + for (const needType of needTypes) { + results[needType] = await calculateMaterialsForNeed(needType); + } + + calculationResults.value = results; + isCalculating.value = false; + + return results; + } + + /** + * Gets the current results for a specific need type + * @param needType The need type to get results for + * @returns Computed array of material calculations + */ + function getResultsForNeed( + needType: Ref + ): ComputedRef { + return computed(() => calculationResults.value[needType.value]); + } + + return { + isCalculating, + calculationResults, + calculateMaterialsForNeed, + calculateAllNeeds, + getResultsForNeed, + }; +} diff --git a/src/features/government/upkeepCalculations.types.d.ts b/src/features/government/upkeepCalculations.types.d.ts index f6555f93..80b64cf0 100644 --- a/src/features/government/upkeepCalculations.types.d.ts +++ b/src/features/government/upkeepCalculations.types.d.ts @@ -33,3 +33,13 @@ export interface IUpkeepMaterialCalculation { cxPrice: number; relativePrice: number; } + +export interface IUpkeepBuildingSummary { + ticker: string; + totalPricePerNeed: number; // Sum of $/Need of all materials + totalDailyCost: number; // Sum of (cxPrice * qtyPerDay) of all materials + materialsAvailable: number; // How many have price > 0 + materialsTotal: number; // Total materials for the building + needProvided: number; // Need that the building provides + isComplete: boolean; // materialsAvailable === materialsTotal +} From b7f351f32345109a17cb950b7601eed1de42b58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Cancio=20V=C3=A1zquez?= Date: Wed, 21 Jan 2026 12:33:40 +0100 Subject: [PATCH 3/6] fix(government): add building names, improve UI and fix double load --- .../components/UpkeepPriceCalculator.vue | 29 ++++++++----------- .../composables/useUpkeepBuildingSummary.ts | 1 + src/features/government/upkeepCalculations.ts | 14 +++++++++ .../government/upkeepCalculations.types.d.ts | 2 ++ 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/features/government/components/UpkeepPriceCalculator.vue b/src/features/government/components/UpkeepPriceCalculator.vue index be21222a..52aa9779 100644 --- a/src/features/government/components/UpkeepPriceCalculator.vue +++ b/src/features/government/components/UpkeepPriceCalculator.vue @@ -1,5 +1,5 @@ - - - - + calc.buildingTicker === buildingTicker - ); - - if (buildingMaterials.length === 0) return undefined; - - // Calculate aggregates - const materialsWithPrice = buildingMaterials.filter( - (calc) => calc.cxPrice > 0 - ); - const totalPricePerNeed = materialsWithPrice.reduce( - (sum, calc) => sum + calc.pricePerNeed, - 0 - ); - const totalDailyCost = materialsWithPrice.reduce( - (sum, calc) => sum + calc.cxPrice * calc.qtyPerDay, - 0 - ); - - // Get need provided (from the first material, they all have the same) - const needProvided = buildingMaterials[0].needProvided; - - return { - ticker: buildingTicker, - name: building.name, - totalPricePerNeed, - totalDailyCost, - materialsAvailable: materialsWithPrice.length, - materialsTotal: building.materials.length, - needProvided, - isComplete: materialsWithPrice.length === building.materials.length, - }; - } - - /** - * Calculates summaries for all buildings providing a specific need - * @param needType The need type to calculate summaries for - * @param materialCalculations All material calculations for this need type - * @returns Array of building summaries sorted by total price per need - */ - function calculateAllBuildingSummaries( - needType: UpkeepNeedType, - materialCalculations: IUpkeepMaterialCalculation[] - ): IUpkeepBuildingSummary[] { - const buildings = getBuildingsForNeed(needType); - const summaries: IUpkeepBuildingSummary[] = []; - - for (const building of buildings) { - const summary = calculateBuildingSummary( - building.ticker, - materialCalculations - ); - if (summary) { - summaries.push(summary); - } - } - - // Sort by total price per need ascending - // Put incomplete buildings (missing material prices) at the end - summaries.sort((a, b) => { - if (!a.isComplete && !b.isComplete) { - return a.totalPricePerNeed - b.totalPricePerNeed; - } - if (!a.isComplete) return 1; - if (!b.isComplete) return -1; - return a.totalPricePerNeed - b.totalPricePerNeed; - }); - - return summaries; - } - - return { - calculateBuildingSummary, - calculateAllBuildingSummaries, - }; -} diff --git a/src/features/government/composables/useUpkeepPriceCalculator.ts b/src/features/government/composables/useUpkeepPriceCalculator.ts index 7db90c0a..fac17471 100644 --- a/src/features/government/composables/useUpkeepPriceCalculator.ts +++ b/src/features/government/composables/useUpkeepPriceCalculator.ts @@ -5,10 +5,7 @@ import { usePrice } from "@/features/cx/usePrice"; import { useUpkeepBuildings } from "./useUpkeepBuildings"; // Calculations -import { - calculatePricePerNeed, - calculateRelativePrice, -} from "@/features/government/upkeepCalculations"; +import { calculatePricePerNeed } from "@/features/government/upkeepCalculations"; // Types & Interfaces import { @@ -65,19 +62,18 @@ export async function useUpkeepPriceCalculator( effectiveNeed ); - calculations.push({ - ticker: material.ticker, - buildingTicker: building.ticker, - qtyPerDay: material.qtyPerDay, - needProvided, - pricePerNeed, - cxPrice, - relativePrice: 0, // Will be calculated after sorting - }); + calculations.push({ + ticker: material.ticker, + buildingTicker: building.ticker, + qtyPerDay: material.qtyPerDay, + needProvided, + pricePerNeed, + cxPrice, + }); } } - // Sort by price per need ascending, but put items with no price (0) at the end + // Sort by price per need ascending, but put items with no price (0) at the end calculations.sort((a, b) => { if (a.cxPrice <= 0 && b.cxPrice <= 0) return 0; if (a.cxPrice <= 0) return 1; @@ -85,22 +81,6 @@ export async function useUpkeepPriceCalculator( return a.pricePerNeed - b.pricePerNeed; }); - // Calculate relative prices using the lowest PRICED material as anchor - const pricedCalculations = calculations.filter((c) => c.cxPrice > 0); - if (pricedCalculations.length > 0) { - const lowestPricePerNeed = pricedCalculations[0].pricePerNeed; - for (const calc of calculations) { - if (calc.cxPrice > 0) { - calc.relativePrice = calculateRelativePrice( - calc.cxPrice, - calc.pricePerNeed, - lowestPricePerNeed - ); - } - // Materials with no price keep relativePrice = 0 - } - } - return calculations; } diff --git a/src/features/government/upkeepCalculations.constants.ts b/src/features/government/upkeepCalculations.constants.ts index de3591f1..93fc9a40 100644 --- a/src/features/government/upkeepCalculations.constants.ts +++ b/src/features/government/upkeepCalculations.constants.ts @@ -25,7 +25,7 @@ export const UPKEEP_BUILDINGS: IUpkeepBuilding[] = [ }, { ticker: "SDP", - name: "Space Defense Platform", + name: "Security Drone Post", materials: [ { ticker: "POW", qtyPerDay: 1 }, { ticker: "RAD", qtyPerDay: 0.47 }, @@ -96,7 +96,7 @@ export const UPKEEP_BUILDINGS: IUpkeepBuilding[] = [ }, { ticker: "4DA", - name: "4D Arena", + name: "4D Arcades", materials: [ { ticker: "POW", qtyPerDay: 2 }, { ticker: "MHP", qtyPerDay: 2 }, @@ -109,7 +109,7 @@ export const UPKEEP_BUILDINGS: IUpkeepBuilding[] = [ }, { ticker: "ACA", - name: "Academy", + name: "Art Cafe", materials: [ { ticker: "COF", qtyPerDay: 8 }, { ticker: "OLF", qtyPerDay: 2 }, @@ -133,7 +133,7 @@ export const UPKEEP_BUILDINGS: IUpkeepBuilding[] = [ }, { ticker: "VRT", - name: "Virtual Reality Theater", + name: "VR Theater", materials: [ { ticker: "POW", qtyPerDay: 1.4 }, { ticker: "MHP", qtyPerDay: 2 }, @@ -146,7 +146,7 @@ export const UPKEEP_BUILDINGS: IUpkeepBuilding[] = [ }, { ticker: "PBH", - name: "Public Broadcast Hub", + name: "Planetary Broadcasting Hub", materials: [ { ticker: "OFF", qtyPerDay: 10 }, { ticker: "MHP", qtyPerDay: 1 }, diff --git a/src/features/government/upkeepCalculations.ts b/src/features/government/upkeepCalculations.ts index 94e3b26b..262ced19 100644 --- a/src/features/government/upkeepCalculations.ts +++ b/src/features/government/upkeepCalculations.ts @@ -31,22 +31,6 @@ export function calculatePricePerNeed( return materialPrice / needPerQty; } -/** - * Calculates the relative price compared to the lowest price per need - * @param materialPrice The CX price of the material - * @param pricePerNeed The price per need for this material - * @param lowestPricePerNeed The lowest price per need (anchor) - * @returns Relative price - */ -export function calculateRelativePrice( - materialPrice: number, - pricePerNeed: number, - lowestPricePerNeed: number -): number { - if (pricePerNeed === 0 || pricePerNeed === Infinity) return materialPrice; - return (materialPrice / pricePerNeed) * lowestPricePerNeed; -} - /** * Gets all buildings that provide a specific need type * @param needType The type of need @@ -95,14 +79,13 @@ export async function calculateMaterialsForNeed( effectiveNeed ); - calculations.push({ + calculations.push({ ticker: material.ticker, buildingTicker: building.ticker, qtyPerDay: material.qtyPerDay, needProvided, pricePerNeed, cxPrice, - relativePrice: 0, // Will be calculated after sorting }); } } @@ -115,21 +98,5 @@ export async function calculateMaterialsForNeed( return a.pricePerNeed - b.pricePerNeed; }); - // Calculate relative prices using the lowest PRICED material as anchor - const pricedCalculations = calculations.filter((c) => c.cxPrice > 0); - if (pricedCalculations.length > 0) { - const lowestPricePerNeed = pricedCalculations[0].pricePerNeed; - for (const calc of calculations) { - if (calc.cxPrice > 0) { - calc.relativePrice = calculateRelativePrice( - calc.cxPrice, - calc.pricePerNeed, - lowestPricePerNeed - ); - } - // Materials with no price keep relativePrice = 0 - } - } - return calculations; } diff --git a/src/features/government/upkeepCalculations.types.d.ts b/src/features/government/upkeepCalculations.types.d.ts index e4f68bd2..1cd723cf 100644 --- a/src/features/government/upkeepCalculations.types.d.ts +++ b/src/features/government/upkeepCalculations.types.d.ts @@ -32,16 +32,4 @@ export interface IUpkeepMaterialCalculation { needProvided: number; pricePerNeed: number; cxPrice: number; - relativePrice: number; -} - -export interface IUpkeepBuildingSummary { - ticker: string; - name: string; // Human-readable building name - totalPricePerNeed: number; // Sum of $/Need of all materials - totalDailyCost: number; // Sum of (cxPrice * qtyPerDay) of all materials - materialsAvailable: number; // How many have price > 0 - materialsTotal: number; // Total materials for the building - needProvided: number; // Need that the building provides - isComplete: boolean; // materialsAvailable === materialsTotal } diff --git a/src/tests/features/government/upkeepCalculations.test.ts b/src/tests/features/government/upkeepCalculations.test.ts index 316f4816..42100c65 100644 --- a/src/tests/features/government/upkeepCalculations.test.ts +++ b/src/tests/features/government/upkeepCalculations.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { calculatePricePerNeed, - calculateRelativePrice, getBuildingsForNeed, getBuildingNeedCount, calculateMaterialsForNeed, @@ -68,31 +67,6 @@ describe("Government: Upkeep Calculations", () => { }); }); - describe("calculateRelativePrice", () => { - it("should calculate relative price correctly", () => { - // Material price: 100, price per need: 2, lowest price per need: 1 - // Relative price = (100 / 2) * 1 = 50 - const result = calculateRelativePrice(100, 2, 1); - expect(result).toBe(50); - }); - - it("should return material price when pricePerNeed is 0", () => { - const result = calculateRelativePrice(100, 0, 1); - expect(result).toBe(100); - }); - - it("should return material price when pricePerNeed is Infinity", () => { - const result = calculateRelativePrice(100, Infinity, 1); - expect(result).toBe(100); - }); - - it("should handle same price per need as anchor", () => { - // When price per need equals lowest, relative price equals material price - const result = calculateRelativePrice(100, 2, 2); - expect(result).toBe(100); - }); - }); - describe("getBuildingsForNeed", () => { it("should return buildings that provide safety", () => { const buildings = getBuildingsForNeed("safety"); @@ -285,49 +259,5 @@ describe("Government: Upkeep Calculations", () => { } }); - it("should calculate relative prices correctly", async () => { - const mockGetPrice = async (ticker: string) => { - const prices: Record = { - DW: 50, - OFF: 100, - SUN: 500, - }; - return prices[ticker] || 0; - }; - - const calculations = await calculateMaterialsForNeed( - "safety", - mockGetPrice - ); - - const pricedCalcs = calculations.filter((c) => c.cxPrice > 0); - - if (pricedCalcs.length > 0) { - // The first priced calculation should have relativePrice equal to cxPrice - // because it has the lowest pricePerNeed - const lowestCalc = pricedCalcs[0]; - expect(lowestCalc.relativePrice).toBe(lowestCalc.cxPrice); - } - }); - - it("should keep relativePrice as 0 for materials with no price", async () => { - const mockGetPrice = async (ticker: string) => { - const prices: Record = { - DW: 50, - }; - return prices[ticker] || 0; - }; - - const calculations = await calculateMaterialsForNeed( - "safety", - mockGetPrice - ); - - const zeroPriceCalcs = calculations.filter((c) => c.cxPrice === 0); - - for (const calc of zeroPriceCalcs) { - expect(calc.relativePrice).toBe(0); - } - }); }); });