Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/assets/help/tools_upkeep_price_calculator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
The Upkeep Price Calculator helps you find the most cost-effective materials and buildings to fulfill population upkeep needs (Safety, Health, Comfort, Culture, and Education) on governed planets.

## Building Summary

Shows an overview of each infrastructure building for the selected need, sorted by total cost efficiency. Use this to quickly compare which building is cheapest to operate.

- **Total $/Need**: Sum of $/Need for all materials the building consumes.
- **Daily Cost**: Total daily operating cost of the building.
- **Need/Day**: Amount of need the building provides per day.

A warning icon indicates some materials lack market prices, making the totals incomplete.

## Material Details

Lists all materials consumed by buildings for the selected need, sorted by cost efficiency.

- **$/Need**: Cost per unit of need provided. Lower is better.
- **CX Price**: Market price based on your exchange preferences.
- **Relative Price**: Price a material would need to match the best option's efficiency. If CX Price drops below this, it becomes more efficient.
- **Qty/Day**: Material consumed per day by the building.

Buildings providing multiple needs (like EMC for Safety+Health, or WCE for Health+Comfort) are accounted for in the calculations.
177 changes: 177 additions & 0 deletions src/features/government/components/UpkeepPriceCalculator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { ComputedRef, Ref, computed, ref, watch } from "vue";

// Composables
import { useUpkeepBuildings } from "@/features/government/composables/useUpkeepBuildings";
import { useUpkeepPriceCalculator } from "@/features/government/composables/useUpkeepPriceCalculator";

// Util
import { formatNumber } from "@/util/numbers";
import { capitalizeString } from "@/util/text";

// Components
import MaterialTile from "@/features/material_tile/components/MaterialTile.vue";

// Types & Interfaces
import {
IUpkeepMaterialCalculation,
UpkeepNeedType,
} from "@/features/government/upkeepCalculations.types";

// UI
import { PProgressBar, PButton, PButtonGroup } from "@/ui";
import { XNDataTable, XNDataTableColumn } from "@skit/x.naive-ui";

const props = defineProps({
cxUuid: {
type: String,
required: false,
default: undefined,
},
});

const cxUuid: ComputedRef<string | undefined> = computed(() => props.cxUuid);
const planetNaturalId: Ref<string | undefined> = ref(undefined);

// Composables
const { needTypes } = useUpkeepBuildings();

// State
const isCalculating: Ref<boolean> = ref(true);
const selectedNeedType: Ref<UpkeepNeedType> = ref("safety");
const calculationResults: Ref<
Record<UpkeepNeedType, IUpkeepMaterialCalculation[]>
> = ref({
safety: [],
health: [],
comfort: [],
culture: [],
education: [],
});

// Computed
const currentResults: ComputedRef<IUpkeepMaterialCalculation[]> = computed(
() => calculationResults.value[selectedNeedType.value]
);

async function calculate() {
isCalculating.value = true;

const { calculateAllNeeds } = await useUpkeepPriceCalculator(
cxUuid,
planetNaturalId
);
calculationResults.value = await calculateAllNeeds();

isCalculating.value = false;
}

watch(
() => props.cxUuid,
async () => {
await calculate();
},
{ immediate: true }
);
</script>

<template>
<div v-if="isCalculating" class="w-full flex justify-center">
<div class="text-center w-100 py-3">
<PProgressBar :step="1" :total="2" />
<div class="pt-3 text-xs text-white/60">
Calculating Upkeep Material Prices
</div>
</div>
</div>
<div v-else>
<!-- Need Type Selection -->
<div class="mb-4">
<PButtonGroup>
<PButton
v-for="needType in needTypes"
:key="needType"
:type="selectedNeedType === needType ? 'primary' : 'secondary'"
@click="selectedNeedType = needType">
{{ capitalizeString(needType) }}
</PButton>
</PButtonGroup>
</div>

<!-- Material Details Section -->
<div>
<h3 class="text-sm font-semibold text-white/80 mb-3">
Material Details
</h3>
<XNDataTable
:data="currentResults"
striped
:pagination="{ pageSize: 50 }">
<XNDataTableColumn key="ticker" title="Material" sorter="default">
<template #render-cell="{ rowData }">
<MaterialTile
:key="`${rowData.ticker}-${rowData.buildingTicker}`"
:ticker="rowData.ticker"
:amount="null" />
</template>
</XNDataTableColumn>
<XNDataTableColumn
key="buildingTicker"
title="Building"
sorter="default">
<template #render-cell="{ rowData }">
<span class="font-bold">{{ rowData.buildingTicker }}</span>
</template>
</XNDataTableColumn>
<XNDataTableColumn
key="pricePerNeed"
title="$/Need"
sorter="default">
<template #title>
<div class="text-end">$/Need</div>
</template>
<template #render-cell="{ rowData }">
<div
class="text-end text-nowrap"
:class="rowData.cxPrice <= 0 ? 'text-white/40' : ''">
<template v-if="rowData.cxPrice > 0">
{{ formatNumber(rowData.pricePerNeed, 4) }}
</template>
<template v-else>-</template>
</div>
</template>
</XNDataTableColumn>
<XNDataTableColumn key="cxPrice" title="CX Price" sorter="default">
<template #title>
<div class="text-end">CX Price</div>
</template>
<template #render-cell="{ rowData }">
<div
class="text-end text-nowrap"
:class="rowData.cxPrice <= 0 ? 'text-white/40' : ''">
<template v-if="rowData.cxPrice > 0">
{{ formatNumber(rowData.cxPrice, 2) }}
<span class="pl-1 font-light text-white/50">$</span>
</template>
<template v-else>-</template>
</div>
</template>
</XNDataTableColumn>

<XNDataTableColumn
key="qtyPerDay"
title="Qty/Day"
sorter="default">
<template #title>
<div class="text-end">Qty/Day</div>
</template>
<template #render-cell="{ rowData }">
<div class="text-end text-nowrap">
{{ formatNumber(rowData.qtyPerDay, 2) }}
</div>
</template>
</XNDataTableColumn>
</XNDataTable>
</div>
</div>
</template>
64 changes: 64 additions & 0 deletions src/features/government/composables/useUpkeepBuildings.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
132 changes: 132 additions & 0 deletions src/features/government/composables/useUpkeepPriceCalculator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Ref, ref, ComputedRef, computed } from "vue";

// Composables
import { usePrice } from "@/features/cx/usePrice";
import { useUpkeepBuildings } from "./useUpkeepBuildings";

// Calculations
import { calculatePricePerNeed } 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<string | undefined>,
planetNaturalId: Ref<string | undefined> = ref(undefined)
) {
const { getPrice } = await usePrice(cxUuid, planetNaturalId);
const { getBuildingsForNeed, getBuildingNeedCount, needTypes } =
useUpkeepBuildings();

const isCalculating: Ref<boolean> = ref(false);
const calculationResults: Ref<
Record<UpkeepNeedType, IUpkeepMaterialCalculation[]>
> = 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<IUpkeepMaterialCalculation[]> {
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,
});
}
}

// 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;
});

return calculations;
}

/**
* Calculates material prices for all need types
* @returns Record of all need types with their material calculations
*/
async function calculateAllNeeds(): Promise<
Record<UpkeepNeedType, IUpkeepMaterialCalculation[]>
> {
isCalculating.value = true;

const results: Record<UpkeepNeedType, IUpkeepMaterialCalculation[]> = {
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<UpkeepNeedType>
): ComputedRef<IUpkeepMaterialCalculation[]> {
return computed(() => calculationResults.value[needType.value]);
}

return {
isCalculating,
calculationResults,
calculateMaterialsForNeed,
calculateAllNeeds,
getResultsForNeed,
};
}
Loading