diff --git a/src/features/api/gameData.types.d.ts b/src/features/api/gameData.types.d.ts index 2c1158071..5b238f927 100644 --- a/src/features/api/gameData.types.d.ts +++ b/src/features/api/gameData.types.d.ts @@ -132,6 +132,19 @@ export interface IPlanetCOGCProgram { type PLANET_COGCPROGRAM_STATUS_TYPE = "ACTIVE" | "ON_STRIKE" | "PLANNED"; +export type PLANET_WORKFORCE_LEVEL_TYPE = + | "PIONEER" + | "SETTLER" + | "TECHNICIAN" + | "ENGINEER" + | "SCIENTIST"; + +export interface IPlanetProductionFee { + category: BUILDING_EXPERTISE_TYPE; + workforce_level: PLANET_WORKFORCE_LEVEL_TYPE; + fee_amount: number; +} + export interface IPlanet { planet_id: string; planet_natural_id: string; @@ -154,6 +167,8 @@ export interface IPlanet { resources: IPlanetResource[]; cogc_programs: IPlanetCOGCProgram[]; active_cogc_program_type: PLANET_COGCPROGRAM_TYPE | null; + // optional: not part of the payload until backend support is deployed + production_fees?: IPlanetProductionFee[]; } export interface IFIOStorageItem { diff --git a/src/features/api/schemas/gameData.schemas.ts b/src/features/api/schemas/gameData.schemas.ts index 7ce837d1b..5ee909fdd 100644 --- a/src/features/api/schemas/gameData.schemas.ts +++ b/src/features/api/schemas/gameData.schemas.ts @@ -10,6 +10,7 @@ import { IBuilding, IPlanetResource, IPlanetCOGCProgram, + IPlanetProductionFee, IPlanet, IFIOStorageItem, IFIOSitePlanetBuildingMaterial, @@ -156,6 +157,20 @@ const PLANET_COGCPGROGRAM_STATUS_TYPE_ZOD = z.enum([ "PLANNED", ]); +const PLANET_WORKFORCE_LEVEL_TYPE_ZOD = z.enum([ + "PIONEER", + "SETTLER", + "TECHNICIAN", + "ENGINEER", + "SCIENTIST", +]); + +const PlanetProductionFeeSchema: z.ZodType = z.object({ + category: EXPERTISE_TYPE_ZOD, + workforce_level: PLANET_WORKFORCE_LEVEL_TYPE_ZOD, + fee_amount: z.number(), +}); + export const PlanetSchema: z.ZodType = z.object({ planet_id: z.string().min(32).max(32), planet_natural_id: z.string(), @@ -178,6 +193,7 @@ export const PlanetSchema: z.ZodType = z.object({ resources: z.array(PlanetResourceSchema), cogc_programs: z.array(PlanetCOGCProgramSchema), active_cogc_program_type: PLANET_COGCPROGRAM_TYPE_ZOD.nullable(), + production_fees: z.array(PlanetProductionFeeSchema).optional(), }); export const PlanetMultiplePayload: z.ZodType = diff --git a/src/features/planning/calculations/productionFeeCalculations.ts b/src/features/planning/calculations/productionFeeCalculations.ts new file mode 100644 index 000000000..f813ccf11 --- /dev/null +++ b/src/features/planning/calculations/productionFeeCalculations.ts @@ -0,0 +1,62 @@ +// Types & Interfaces +import { + IBuilding, + IPlanetProductionFee, + PLANET_WORKFORCE_LEVEL_TYPE, +} from "@/features/api/gameData.types"; + +function getWorkforceAmount( + building: IBuilding, + level: PLANET_WORKFORCE_LEVEL_TYPE +): number { + switch (level) { + case "PIONEER": + return building.pioneers; + case "SETTLER": + return building.settlers; + case "TECHNICIAN": + return building.technicians; + case "ENGINEER": + return building.engineers; + case "SCIENTIST": + return building.scientists; + } +} + +/** + * Calculates a buildings production fee rate per 24h of runtime as the + * workforce-weighted average of the planets per-tier daily rates for + * the buildings expertise, charged per building following ingame logic + * @author raukk + * + * @export + * @param {IBuilding} building Building Data + * @param {IPlanetProductionFee[] | undefined} fees Planet Fee Data + * @returns {number} Fee Rate per 24h, 0 if unknown + */ +export function calculateProductionFeeRate( + building: IBuilding, + fees: IPlanetProductionFee[] | undefined +): number { + if (!fees || building.expertise === null) return 0; + + const workers: number = + building.pioneers + + building.settlers + + building.technicians + + building.engineers + + building.scientists; + if (workers === 0) return 0; + + const weighted: number = fees.reduce( + (sum, fee) => + fee.category === building.expertise + ? sum + + getWorkforceAmount(building, fee.workforce_level) * + fee.fee_amount + : sum, + 0 + ); + + return weighted / workers; +} diff --git a/src/features/planning/components/PlanOverview.vue b/src/features/planning/components/PlanOverview.vue index fbf4b9b93..ee9a67dbd 100644 --- a/src/features/planning/components/PlanOverview.vue +++ b/src/features/planning/components/PlanOverview.vue @@ -74,6 +74,23 @@ ȼ + + + {{ + $t( + "plan.components.overview.table.production_fees" + ) + }} + + + {{ + formatNumber( + overviewData.dailyProductionFeeCost + ) + }} + ȼ + + {{ $t("plan.components.overview.table.plan_cost") }} diff --git a/src/features/planning/usePlanCalculation.ts b/src/features/planning/usePlanCalculation.ts index 601dd0adf..656c982a4 100644 --- a/src/features/planning/usePlanCalculation.ts +++ b/src/features/planning/usePlanCalculation.ts @@ -28,6 +28,7 @@ import { getVolumeOfAllStorages, getWeightOfAllStorages, } from "@/features/planning/calculations/infrastructureCalculations"; +import { calculateProductionFeeRate } from "@/features/planning/calculations/productionFeeCalculations"; // Submodule composables import { usePlanCalculationHandlers } from "@/features/planning/usePlanCalculationHandlers"; @@ -458,6 +459,17 @@ export async function usePlanCalculation( "BUY" ); + // government production fee, daily at full utilization + const productionFeeDaily: number = + -1 * + calculateProductionFeeRate( + buildingData, + planetData.production_fees + ); + // fees are charged per order, an idle building pays none + const productionFeeDailyCost: number = + activeRecipes.length > 0 ? productionFeeDaily : 0; + // get recipe options const recipeOptions: IRecipeBuildingOption[] = await Promise.all( buildingRecipes.map(async (br) => { @@ -490,7 +502,8 @@ export async function usePlanCalculation( dailyIncome * maxDailyRuns - dailyCost * maxDailyRuns - constructionCost * -1 * (1 / 180) - - -1 * workforceDailyCost; + -1 * workforceDailyCost - + -1 * productionFeeDaily; // Recipe option ROI const roi: number = (constructionCost * -1) / dailyRevenue; @@ -631,6 +644,7 @@ export async function usePlanCalculation( constructionCost: constructionCost, workforceMaterials: workforceMaterials, workforceDailyCost: workforceDailyCost, + productionFeeDailyCost: productionFeeDailyCost, dailyRevenue: 0, expertise: buildingData.expertise, }; @@ -651,6 +665,7 @@ export async function usePlanCalculation( building.dailyRevenue = productionRevenue + workforceDailyCost * building.amount + + productionFeeDailyCost * building.amount + (1 / 180) * constructionCost; buildings.push(building); @@ -766,10 +781,21 @@ export async function usePlanCalculation( ) * (1 / 180); + const dailyProductionFeeCost: number = + productionResult.buildings.reduce( + (sum, element) => + sum + element.productionFeeDailyCost * -1 * element.amount, + 0 + ); + const profit: number = - materialRevenue - materialCost - dailyDegradationCost; + materialRevenue - + materialCost - + dailyDegradationCost - + dailyProductionFeeCost; - const cost: number = materialCost + dailyDegradationCost; + const cost: number = + materialCost + dailyDegradationCost + dailyProductionFeeCost; // calculate overview overviewData.value = await calculateOverview( @@ -832,6 +858,12 @@ export async function usePlanCalculation( const dailyDegradationCost: number = totalProductionConstructionCost / 180; + const dailyProductionFee: number = production.buildings.reduce( + (sum, current) => + sum + current.productionFeeDailyCost * current.amount, + 0 + ); + const constructionMaterials = await calculateConstructionMaterials( infrastructure, production.buildings @@ -857,13 +889,17 @@ export async function usePlanCalculation( ); const profit: number = - dailyProfit - -1 * dailyDegradationCost - -1 * dailyCost; + dailyProfit - + -1 * dailyDegradationCost - + -1 * dailyCost - + -1 * dailyProductionFee; return { dailyCost: dailyCost * -1, dailyProfit: dailyProfit * 1, totalConstructionCost, dailyDegradationCost: dailyDegradationCost * -1, + dailyProductionFeeCost: dailyProductionFee * -1, profit, roi: totalConstructionCost / profit, }; @@ -874,6 +910,7 @@ export async function usePlanCalculation( dailyProfit: 0, totalConstructionCost: 0, dailyDegradationCost: 0, + dailyProductionFeeCost: 0, profit: 0, roi: 0, }); diff --git a/src/features/planning/usePlanCalculation.types.ts b/src/features/planning/usePlanCalculation.types.ts index 5272ed720..e933c1b50 100644 --- a/src/features/planning/usePlanCalculation.types.ts +++ b/src/features/planning/usePlanCalculation.types.ts @@ -134,6 +134,7 @@ export interface IProductionBuilding { constructionCost: number; workforceMaterials: IMaterialIOMinimal[]; workforceDailyCost: number; + productionFeeDailyCost: number; dailyRevenue: number; expertise: BUILDING_EXPERTISE_TYPE | null; } @@ -306,6 +307,7 @@ export interface IOverviewData { dailyProfit: number; totalConstructionCost: number; dailyDegradationCost: number; + dailyProductionFeeCost: number; profit: number; roi: number; } diff --git a/src/locales/en_US/plan.json b/src/locales/en_US/plan.json index 1b01bf38c..54b45e76d 100644 --- a/src/locales/en_US/plan.json +++ b/src/locales/en_US/plan.json @@ -201,6 +201,7 @@ "table": { "daily_cost": "Daily Cost", "degradation": "Degradation", + "production_fees": "Production Fees", "plan_cost": "Plan Cost", "daily_profit": "Daily Profit", "roi": "ROI", diff --git a/src/tests/features/planning/calculations/productionFeeCalculations.test.ts b/src/tests/features/planning/calculations/productionFeeCalculations.test.ts new file mode 100644 index 000000000..5ededf007 --- /dev/null +++ b/src/tests/features/planning/calculations/productionFeeCalculations.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; + +import { calculateProductionFeeRate } from "@/features/planning/calculations/productionFeeCalculations"; + +// Types & Interfaces +import { IBuilding, IPlanetProductionFee } from "@/features/api/gameData.types"; + +const fakeBuilding = { + ticker: "SME", + expertise: "METALLURGY", + pioneers: 50, + settlers: 20, + technicians: 0, + engineers: 0, + scientists: 0, +} as unknown as IBuilding; + +const fakeFees: IPlanetProductionFee[] = [ + { category: "METALLURGY", workforce_level: "PIONEER", fee_amount: 50 }, + { category: "METALLURGY", workforce_level: "SETTLER", fee_amount: 80 }, + { category: "METALLURGY", workforce_level: "SCIENTIST", fee_amount: 1500 }, + { category: "AGRICULTURE", workforce_level: "PIONEER", fee_amount: 999 }, +]; + +describe("productionFeeCalculations", () => { + describe("calculateProductionFeeRate", () => { + it("amortizes the tier rates over the buildings workforce", () => { + // (50 * 50 + 20 * 80) / 70 workers + expect( + calculateProductionFeeRate(fakeBuilding, fakeFees) + ).toBeCloseTo(4100 / 70, 8); + }); + + it("charges a single tier building its tier rate flat", () => { + const singleTier = { + ...fakeBuilding, + pioneers: 100, + settlers: 0, + } as IBuilding; + expect(calculateProductionFeeRate(singleTier, fakeFees)).toBe(50); + }); + + it("is independent of the buildings worker count", () => { + const doubled = { + ...fakeBuilding, + pioneers: 100, + settlers: 40, + } as IBuilding; + expect(calculateProductionFeeRate(doubled, fakeFees)).toBeCloseTo( + calculateProductionFeeRate(fakeBuilding, fakeFees), + 8 + ); + }); + + it("returns 0 without any workforce", () => { + const empty = { + ...fakeBuilding, + pioneers: 0, + settlers: 0, + } as IBuilding; + expect(calculateProductionFeeRate(empty, fakeFees)).toBe(0); + }); + + it("reproduces the APEX handbook worked example", () => { + // (10 * 15 + 25 * 12) / 35 = 12.9 + const polymerPlant = { + ticker: "POL", + expertise: "CHEMISTRY", + pioneers: 10, + settlers: 25, + technicians: 0, + engineers: 0, + scientists: 0, + } as unknown as IBuilding; + + expect( + calculateProductionFeeRate(polymerPlant, [ + { + category: "CHEMISTRY", + workforce_level: "PIONEER", + fee_amount: 15, + }, + { + category: "CHEMISTRY", + workforce_level: "SETTLER", + fee_amount: 12, + }, + ]) + ).toBeCloseTo(450 / 35, 8); + }); + + it("returns 0 on unknown fees", () => { + expect(calculateProductionFeeRate(fakeBuilding, undefined)).toBe(0); + }); + + it("returns 0 without building expertise", () => { + const noExpertise = { + ...fakeBuilding, + expertise: null, + } as IBuilding; + expect(calculateProductionFeeRate(noExpertise, fakeFees)).toBe(0); + }); + + it("returns 0 on missing industry fee entries", () => { + const otherExpertise = { + ...fakeBuilding, + expertise: "CONSTRUCTION", + } as IBuilding; + expect(calculateProductionFeeRate(otherExpertise, fakeFees)).toBe( + 0 + ); + }); + }); +});