From 747f9af9590e8f418bc836db9eeb1b48259e3c81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:05:50 +0000 Subject: [PATCH 1/4] feat(plan): show government production fees in plan overview Fetches government-set production fee rates from the FIO REST API (rest.fnar.net, non-blocking) and charges each active production building the workforce-weighted daily fee rate of its expertise. Shown as a Production Fees row in the plan overview and included in daily profit, cost and ROI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PFsCfBfPMjbxDYBoh65xYC --- src/features/api/fioData.api.ts | 38 ++++++ src/features/api/fioData.types.ts | 11 ++ src/features/api/schemas/fioData.schemas.ts | 45 +++++++ .../calculations/productionFeeCalculations.ts | 52 +++++++++ .../planning/components/PlanOverview.vue | 17 +++ src/features/planning/usePlanCalculation.ts | 60 +++++++++- .../planning/usePlanCalculation.types.ts | 2 + src/lib/config.ts | 3 + src/lib/query_cache/queryRepository.ts | 25 ++++ src/lib/query_cache/queryRepository.types.ts | 5 + src/locales/en_US/plan.json | 1 + .../productionFeeCalculations.test.ts | 110 ++++++++++++++++++ 12 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 src/features/api/fioData.api.ts create mode 100644 src/features/api/fioData.types.ts create mode 100644 src/features/api/schemas/fioData.schemas.ts create mode 100644 src/features/planning/calculations/productionFeeCalculations.ts create mode 100644 src/tests/features/planning/calculations/productionFeeCalculations.test.ts diff --git a/src/features/api/fioData.api.ts b/src/features/api/fioData.api.ts new file mode 100644 index 000000000..235fc45d1 --- /dev/null +++ b/src/features/api/fioData.api.ts @@ -0,0 +1,38 @@ +import axios, { AxiosInstance } from "axios"; + +// config +import config from "@/lib/config"; + +// schemas +import { FIOPlanetFeeSchema } from "@/features/api/schemas/fioData.schemas"; + +// types +import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; + +// Own instance: the PRUNplanner auth interceptors and no-cache headers +// on the global axios instance must not apply to the third-party FIO +// API. The inherited no-cache headers are not CORS-safelisted and FIO +// rejects their preflight, so reset the inherited header buckets. +const fioClient: AxiosInstance = axios.create({ + baseURL: config.FIO_BASE_URL, + timeout: 15_000, +}); +fioClient.defaults.headers.get = {}; +fioClient.defaults.headers.common = {}; + +/** + * Calls the FIO /planet/{planetNaturalId} endpoint and extracts the + * government-set production fee data + * @author raukk + * + * @export + * @async + * @param {string} planetNaturalId Planet Natural Id ('OT-580b') + * @returns {Promise} Planet Production Fees + */ +export async function callFIOPlanetFees( + planetNaturalId: string +): Promise { + const { data } = await fioClient.get(`/planet/${planetNaturalId}`); + return FIOPlanetFeeSchema.parse(data); +} diff --git a/src/features/api/fioData.types.ts b/src/features/api/fioData.types.ts new file mode 100644 index 000000000..7b9b4088f --- /dev/null +++ b/src/features/api/fioData.types.ts @@ -0,0 +1,11 @@ +// Types & Interfaces +import { WORKFORCE_TYPE } from "@/features/planning/usePlanCalculation.types"; + +/** + * Per-industry production fee rates by workforce tier, in the planet's + * local currency per 24h, as set by the planet's governing entity. + */ +export type IFIOProductionFeeTable = Record< + string, + Partial> +>; diff --git a/src/features/api/schemas/fioData.schemas.ts b/src/features/api/schemas/fioData.schemas.ts new file mode 100644 index 000000000..e9b985ea1 --- /dev/null +++ b/src/features/api/schemas/fioData.schemas.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +// Types & Interfaces +import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; +import { WORKFORCE_TYPE } from "@/features/planning/usePlanCalculation.types"; + +const FIO_WORKFORCE_LEVEL_MAP: Record = { + PIONEER: "pioneer", + SETTLER: "settler", + TECHNICIAN: "technician", + ENGINEER: "engineer", + SCIENTIST: "scientist", +}; + +/** + * Parses the production fee subset of the FIO /planet/{id} payload into + * a per-industry, per-workforce fee rate table. Unknown workforce + * levels are skipped instead of failing the whole payload. + */ +export const FIOPlanetFeeSchema = z + .object({ + ProductionFees: z + .array( + z.object({ + Category: z.string(), + WorkforceLevel: z.string(), + FeeAmount: z.number(), + }) + ) + .nullable(), + }) + .transform((raw): IFIOProductionFeeTable => { + const fees: IFIOProductionFeeTable = {}; + + (raw.ProductionFees ?? []).forEach((fee) => { + const workforce: WORKFORCE_TYPE | undefined = + FIO_WORKFORCE_LEVEL_MAP[fee.WorkforceLevel]; + + if (!workforce) return; + + (fees[fee.Category] ??= {})[workforce] = fee.FeeAmount; + }); + + return fees; + }); diff --git a/src/features/planning/calculations/productionFeeCalculations.ts b/src/features/planning/calculations/productionFeeCalculations.ts new file mode 100644 index 000000000..9d8345bef --- /dev/null +++ b/src/features/planning/calculations/productionFeeCalculations.ts @@ -0,0 +1,52 @@ +// Types & Interfaces +import { IBuilding } from "@/features/api/gameData.types"; +import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; +import { WORKFORCE_TYPE } from "@/features/planning/usePlanCalculation.types"; + +const WORKFORCE_BUILDING_FIELD_MAP: Record< + WORKFORCE_TYPE, + "pioneers" | "settlers" | "technicians" | "engineers" | "scientists" +> = { + pioneer: "pioneers", + settler: "settlers", + technician: "technicians", + engineer: "engineers", + scientist: "scientists", +}; + +/** + * A building's production fee rate per 24h of runtime. The rate is + * charged per building, not per employee: the workforce-weighted + * average of the planet's per-tier daily rates for the building's + * expertise, e.g. (10 x 15 + 25 x 12) / (10 + 25) = 12.9. + * @see https://handbook.apex.prosperousuniverse.com/wiki/local-rules/index.html + * @author raukk + * + * @export + * @param {IBuilding} building Building Data + * @param {IFIOProductionFeeTable | null} fees Planet Fee Data, null if unknown + * @returns {number} Fee rate per 24h runtime, 0 if unknown + */ +export function calculateProductionFeeRate( + building: IBuilding, + fees: IFIOProductionFeeTable | null +): number { + if (!fees || building.expertise === null) return 0; + + const feeTable = fees[building.expertise]; + if (!feeTable) return 0; + + const { weighted, workers } = Object.entries( + WORKFORCE_BUILDING_FIELD_MAP + ).reduce( + (acc, [workforce, field]) => ({ + weighted: + acc.weighted + + building[field] * (feeTable[workforce as WORKFORCE_TYPE] ?? 0), + workers: acc.workers + building[field], + }), + { weighted: 0, workers: 0 } + ); + + return workers > 0 ? weighted / workers : 0; +} 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..83b9179cf 100644 --- a/src/features/planning/usePlanCalculation.ts +++ b/src/features/planning/usePlanCalculation.ts @@ -12,6 +12,7 @@ import { import { useMaterialIOUtil } from "@/features/planning/util/materialIO.util"; import { usePrice } from "@/features/cx/usePrice"; import { usePlanetData } from "@/database/services/usePlanetData"; +import { useQuery } from "@/lib/query_cache/useQuery"; // Calculation Utils import { @@ -28,6 +29,7 @@ import { getVolumeOfAllStorages, getWeightOfAllStorages, } from "@/features/planning/calculations/infrastructureCalculations"; +import { calculateProductionFeeRate } from "@/features/planning/calculations/productionFeeCalculations"; // Submodule composables import { usePlanCalculationHandlers } from "@/features/planning/usePlanCalculationHandlers"; @@ -38,6 +40,7 @@ import { optimalProduction } from "@/features/roi_overview/assets/optimalProduct // Types & Interfaces import { IBuilding, IPlanet, IRecipe } from "@/features/api/gameData.types"; +import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; import { IAreaResult, IBuildingConstruction, @@ -107,6 +110,23 @@ export async function usePlanCalculation( ); const planetNaturalId: Ref = toRef(plan.value.planet_natural_id); const planetData: IPlanet = await getPlanet(plan.value.planet_natural_id); + + // government-set production fees, fetched non-blocking from FIO: + // plan calculation must not depend on FIO uptime, until resolved + // (or on failure) fees are unknown and cost 0 + const planetFees: Ref = ref(null); + useQuery("GetFIOPlanetFees", { + planetNaturalId: plan.value.planet_natural_id, + }) + .execute() + .then((fees) => { + if (fees) { + planetFees.value = fees; + refreshKey.value++; + } + }) + .catch(() => {}); + const buildings: ComputedRef = computed( () => data.value.buildings ); @@ -458,6 +478,13 @@ export async function usePlanCalculation( "BUY" ); + // government production fee, daily at full utilization + const productionFeeDaily: number = + -1 * calculateProductionFeeRate(buildingData, planetFees.value); + // 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 +517,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 +659,7 @@ export async function usePlanCalculation( constructionCost: constructionCost, workforceMaterials: workforceMaterials, workforceDailyCost: workforceDailyCost, + productionFeeDailyCost: productionFeeDailyCost, dailyRevenue: 0, expertise: buildingData.expertise, }; @@ -651,6 +680,7 @@ export async function usePlanCalculation( building.dailyRevenue = productionRevenue + workforceDailyCost * building.amount + + productionFeeDailyCost * building.amount + (1 / 180) * constructionCost; buildings.push(building); @@ -766,10 +796,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 +873,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 +904,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 +925,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/lib/config.ts b/src/lib/config.ts index 6938d960f..b376df129 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,6 +1,7 @@ class Config { public readonly API_BASE_URL: string; public readonly SHARE_BASE_URL: string; + public readonly FIO_BASE_URL: string; public readonly GAME_DATA_STALE_MINUTES_BUILDINGS: number; public readonly GAME_DATA_STALE_MINUTES_RECIPES: number; @@ -16,6 +17,8 @@ class Config { this.SHARE_BASE_URL = import.meta.env.VITE_SHARE_BASE_URL || "https://prunplanner.org/shared"; + this.FIO_BASE_URL = + import.meta.env.VITE_FIO_BASE_URL || "https://rest.fnar.net"; this.GAME_DATA_STALE_MINUTES_BUILDINGS = import.meta.env.VITE_GAME_DATA_STALE_MINUTES_BUILDINGS || 24 * 60; diff --git a/src/lib/query_cache/queryRepository.ts b/src/lib/query_cache/queryRepository.ts index f369272e0..1ea8a3202 100644 --- a/src/lib/query_cache/queryRepository.ts +++ b/src/lib/query_cache/queryRepository.ts @@ -37,6 +37,7 @@ import { callExplorationData, callPlanetLastPOPR, } from "@/features/api/gameData.api"; +import { callFIOPlanetFees } from "@/features/api/fioData.api"; import { callClonePlan, callCreatePlan, @@ -82,6 +83,7 @@ import { IPopulationReport, IRecipe, } from "@/features/api/gameData.types"; +import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; import { ICXEmpireJunction, @@ -286,6 +288,29 @@ export function useQueryRepository() { persist: true, autoRefetch: false, } as IQueryDefinition<{ searchId: string }, IPlanet[]>, + GetFIOPlanetFees: { + key: (params: { planetNaturalId: string }) => [ + "gamedata", + "fio", + "planetfees", + params.planetNaturalId, + ], + fetchFn: async (params: { planetNaturalId: string }) => { + // FIO is a third-party service, a failing call must not + // break plan loading — fees are then unknown + try { + return await callFIOPlanetFees(params.planetNaturalId); + } catch { + return null; + } + }, + expireTime: 60_000 * config.GAME_DATA_STALE_MINUTES_PLANETS, + persist: true, + autoRefetch: false, + } as IQueryDefinition< + { planetNaturalId: string }, + IFIOProductionFeeTable | null + >, PostPlanetSearch: { key: (params: { searchData: IPlanetSearchAdvanced }) => [ "gamedata", diff --git a/src/lib/query_cache/queryRepository.types.ts b/src/lib/query_cache/queryRepository.types.ts index e2b1001ab..13a7e6458 100644 --- a/src/lib/query_cache/queryRepository.types.ts +++ b/src/lib/query_cache/queryRepository.types.ts @@ -12,6 +12,7 @@ import { IPopulationReport, IRecipe, } from "@/features/api/gameData.types"; +import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; import { ICXEmpireJunction, @@ -99,6 +100,10 @@ export interface IQueryRepository { IPlanet[] >; GetPlanetSearchSingle: IQueryDefinition<{ searchId: string }, IPlanet[]>; + GetFIOPlanetFees: IQueryDefinition< + { planetNaturalId: string }, + IFIOProductionFeeTable | null + >; PostPlanetSearch: IQueryDefinition< { searchData: IPlanetSearchAdvanced }, IPlanet[] 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..bd78a4d04 --- /dev/null +++ b/src/tests/features/planning/calculations/productionFeeCalculations.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; + +import { calculateProductionFeeRate } from "@/features/planning/calculations/productionFeeCalculations"; + +// Types & Interfaces +import { IBuilding } from "@/features/api/gameData.types"; +import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; + +const fakeBuilding = { + ticker: "SME", + expertise: "METALLURGY", + pioneers: 50, + settlers: 20, + technicians: 0, + engineers: 0, + scientists: 0, +} as unknown as IBuilding; + +const fakeFees: IFIOProductionFeeTable = { + METALLURGY: { + pioneer: 50, + settler: 80, + technician: 140, + engineer: 800, + scientist: 1500, + }, +}; + +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", () => { + // handbook "Local Rules": a polymer plant of 10 pioneers at 15 + // and 25 settlers at 12 pays (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, { + CHEMISTRY: { pioneer: 15, settler: 12 }, + }) + ).toBeCloseTo(450 / 35, 8); + }); + + it("returns 0 on unknown fees", () => { + expect(calculateProductionFeeRate(fakeBuilding, null)).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 table", () => { + const otherExpertise = { + ...fakeBuilding, + expertise: "CHEMISTRY", + } as IBuilding; + expect(calculateProductionFeeRate(otherExpertise, fakeFees)).toBe( + 0 + ); + }); + }); +}); From c1ec22d5d57634460f300fe47a685c5d5842deaf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:21:40 +0000 Subject: [PATCH 2/4] improve(plan): show government production fees in plan overview Weighted per-building fee rate from the planet's production fee data (optional payload field, pending backend support) charged into daily cost, profit, ROI and recipe option revenue. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PFsCfBfPMjbxDYBoh65xYC --- src/features/api/fioData.api.ts | 38 -------------- src/features/api/fioData.types.ts | 11 ---- src/features/api/gameData.types.d.ts | 15 ++++++ src/features/api/schemas/fioData.schemas.ts | 45 ---------------- src/features/api/schemas/gameData.schemas.ts | 16 ++++++ .../calculations/productionFeeCalculations.ts | 51 ++++++++++--------- src/features/planning/usePlanCalculation.ts | 25 ++------- src/lib/config.ts | 3 -- src/lib/query_cache/queryRepository.ts | 25 --------- src/lib/query_cache/queryRepository.types.ts | 5 -- .../productionFeeCalculations.test.ts | 42 ++++++++------- 11 files changed, 88 insertions(+), 188 deletions(-) delete mode 100644 src/features/api/fioData.api.ts delete mode 100644 src/features/api/fioData.types.ts delete mode 100644 src/features/api/schemas/fioData.schemas.ts diff --git a/src/features/api/fioData.api.ts b/src/features/api/fioData.api.ts deleted file mode 100644 index 235fc45d1..000000000 --- a/src/features/api/fioData.api.ts +++ /dev/null @@ -1,38 +0,0 @@ -import axios, { AxiosInstance } from "axios"; - -// config -import config from "@/lib/config"; - -// schemas -import { FIOPlanetFeeSchema } from "@/features/api/schemas/fioData.schemas"; - -// types -import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; - -// Own instance: the PRUNplanner auth interceptors and no-cache headers -// on the global axios instance must not apply to the third-party FIO -// API. The inherited no-cache headers are not CORS-safelisted and FIO -// rejects their preflight, so reset the inherited header buckets. -const fioClient: AxiosInstance = axios.create({ - baseURL: config.FIO_BASE_URL, - timeout: 15_000, -}); -fioClient.defaults.headers.get = {}; -fioClient.defaults.headers.common = {}; - -/** - * Calls the FIO /planet/{planetNaturalId} endpoint and extracts the - * government-set production fee data - * @author raukk - * - * @export - * @async - * @param {string} planetNaturalId Planet Natural Id ('OT-580b') - * @returns {Promise} Planet Production Fees - */ -export async function callFIOPlanetFees( - planetNaturalId: string -): Promise { - const { data } = await fioClient.get(`/planet/${planetNaturalId}`); - return FIOPlanetFeeSchema.parse(data); -} diff --git a/src/features/api/fioData.types.ts b/src/features/api/fioData.types.ts deleted file mode 100644 index 7b9b4088f..000000000 --- a/src/features/api/fioData.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Types & Interfaces -import { WORKFORCE_TYPE } from "@/features/planning/usePlanCalculation.types"; - -/** - * Per-industry production fee rates by workforce tier, in the planet's - * local currency per 24h, as set by the planet's governing entity. - */ -export type IFIOProductionFeeTable = Record< - string, - Partial> ->; 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/fioData.schemas.ts b/src/features/api/schemas/fioData.schemas.ts deleted file mode 100644 index e9b985ea1..000000000 --- a/src/features/api/schemas/fioData.schemas.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from "zod"; - -// Types & Interfaces -import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; -import { WORKFORCE_TYPE } from "@/features/planning/usePlanCalculation.types"; - -const FIO_WORKFORCE_LEVEL_MAP: Record = { - PIONEER: "pioneer", - SETTLER: "settler", - TECHNICIAN: "technician", - ENGINEER: "engineer", - SCIENTIST: "scientist", -}; - -/** - * Parses the production fee subset of the FIO /planet/{id} payload into - * a per-industry, per-workforce fee rate table. Unknown workforce - * levels are skipped instead of failing the whole payload. - */ -export const FIOPlanetFeeSchema = z - .object({ - ProductionFees: z - .array( - z.object({ - Category: z.string(), - WorkforceLevel: z.string(), - FeeAmount: z.number(), - }) - ) - .nullable(), - }) - .transform((raw): IFIOProductionFeeTable => { - const fees: IFIOProductionFeeTable = {}; - - (raw.ProductionFees ?? []).forEach((fee) => { - const workforce: WORKFORCE_TYPE | undefined = - FIO_WORKFORCE_LEVEL_MAP[fee.WorkforceLevel]; - - if (!workforce) return; - - (fees[fee.Category] ??= {})[workforce] = fee.FeeAmount; - }); - - return fees; - }); 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 index 9d8345bef..b4f015d8d 100644 --- a/src/features/planning/calculations/productionFeeCalculations.ts +++ b/src/features/planning/calculations/productionFeeCalculations.ts @@ -1,17 +1,19 @@ // Types & Interfaces -import { IBuilding } from "@/features/api/gameData.types"; -import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; -import { WORKFORCE_TYPE } from "@/features/planning/usePlanCalculation.types"; +import { + IBuilding, + IPlanetProductionFee, + PLANET_WORKFORCE_LEVEL_TYPE, +} from "@/features/api/gameData.types"; const WORKFORCE_BUILDING_FIELD_MAP: Record< - WORKFORCE_TYPE, + PLANET_WORKFORCE_LEVEL_TYPE, "pioneers" | "settlers" | "technicians" | "engineers" | "scientists" > = { - pioneer: "pioneers", - settler: "settlers", - technician: "technicians", - engineer: "engineers", - scientist: "scientists", + PIONEER: "pioneers", + SETTLER: "settlers", + TECHNICIAN: "technicians", + ENGINEER: "engineers", + SCIENTIST: "scientists", }; /** @@ -24,29 +26,30 @@ const WORKFORCE_BUILDING_FIELD_MAP: Record< * * @export * @param {IBuilding} building Building Data - * @param {IFIOProductionFeeTable | null} fees Planet Fee Data, null if unknown + * @param {IPlanetProductionFee[] | undefined} fees Planet Fee Data * @returns {number} Fee rate per 24h runtime, 0 if unknown */ export function calculateProductionFeeRate( building: IBuilding, - fees: IFIOProductionFeeTable | null + fees: IPlanetProductionFee[] | undefined ): number { if (!fees || building.expertise === null) return 0; - const feeTable = fees[building.expertise]; - if (!feeTable) return 0; + const workers: number = Object.values(WORKFORCE_BUILDING_FIELD_MAP).reduce( + (sum, field) => sum + building[field], + 0 + ); + if (workers === 0) return 0; - const { weighted, workers } = Object.entries( - WORKFORCE_BUILDING_FIELD_MAP - ).reduce( - (acc, [workforce, field]) => ({ - weighted: - acc.weighted + - building[field] * (feeTable[workforce as WORKFORCE_TYPE] ?? 0), - workers: acc.workers + building[field], - }), - { weighted: 0, workers: 0 } + const weighted: number = fees.reduce( + (sum, fee) => + fee.category === building.expertise + ? sum + + building[WORKFORCE_BUILDING_FIELD_MAP[fee.workforce_level]] * + fee.fee_amount + : sum, + 0 ); - return workers > 0 ? weighted / workers : 0; + return weighted / workers; } diff --git a/src/features/planning/usePlanCalculation.ts b/src/features/planning/usePlanCalculation.ts index 83b9179cf..656c982a4 100644 --- a/src/features/planning/usePlanCalculation.ts +++ b/src/features/planning/usePlanCalculation.ts @@ -12,7 +12,6 @@ import { import { useMaterialIOUtil } from "@/features/planning/util/materialIO.util"; import { usePrice } from "@/features/cx/usePrice"; import { usePlanetData } from "@/database/services/usePlanetData"; -import { useQuery } from "@/lib/query_cache/useQuery"; // Calculation Utils import { @@ -40,7 +39,6 @@ import { optimalProduction } from "@/features/roi_overview/assets/optimalProduct // Types & Interfaces import { IBuilding, IPlanet, IRecipe } from "@/features/api/gameData.types"; -import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; import { IAreaResult, IBuildingConstruction, @@ -110,23 +108,6 @@ export async function usePlanCalculation( ); const planetNaturalId: Ref = toRef(plan.value.planet_natural_id); const planetData: IPlanet = await getPlanet(plan.value.planet_natural_id); - - // government-set production fees, fetched non-blocking from FIO: - // plan calculation must not depend on FIO uptime, until resolved - // (or on failure) fees are unknown and cost 0 - const planetFees: Ref = ref(null); - useQuery("GetFIOPlanetFees", { - planetNaturalId: plan.value.planet_natural_id, - }) - .execute() - .then((fees) => { - if (fees) { - planetFees.value = fees; - refreshKey.value++; - } - }) - .catch(() => {}); - const buildings: ComputedRef = computed( () => data.value.buildings ); @@ -480,7 +461,11 @@ export async function usePlanCalculation( // government production fee, daily at full utilization const productionFeeDaily: number = - -1 * calculateProductionFeeRate(buildingData, planetFees.value); + -1 * + calculateProductionFeeRate( + buildingData, + planetData.production_fees + ); // fees are charged per order, an idle building pays none const productionFeeDailyCost: number = activeRecipes.length > 0 ? productionFeeDaily : 0; diff --git a/src/lib/config.ts b/src/lib/config.ts index b376df129..6938d960f 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,7 +1,6 @@ class Config { public readonly API_BASE_URL: string; public readonly SHARE_BASE_URL: string; - public readonly FIO_BASE_URL: string; public readonly GAME_DATA_STALE_MINUTES_BUILDINGS: number; public readonly GAME_DATA_STALE_MINUTES_RECIPES: number; @@ -17,8 +16,6 @@ class Config { this.SHARE_BASE_URL = import.meta.env.VITE_SHARE_BASE_URL || "https://prunplanner.org/shared"; - this.FIO_BASE_URL = - import.meta.env.VITE_FIO_BASE_URL || "https://rest.fnar.net"; this.GAME_DATA_STALE_MINUTES_BUILDINGS = import.meta.env.VITE_GAME_DATA_STALE_MINUTES_BUILDINGS || 24 * 60; diff --git a/src/lib/query_cache/queryRepository.ts b/src/lib/query_cache/queryRepository.ts index 1ea8a3202..f369272e0 100644 --- a/src/lib/query_cache/queryRepository.ts +++ b/src/lib/query_cache/queryRepository.ts @@ -37,7 +37,6 @@ import { callExplorationData, callPlanetLastPOPR, } from "@/features/api/gameData.api"; -import { callFIOPlanetFees } from "@/features/api/fioData.api"; import { callClonePlan, callCreatePlan, @@ -83,7 +82,6 @@ import { IPopulationReport, IRecipe, } from "@/features/api/gameData.types"; -import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; import { ICXEmpireJunction, @@ -288,29 +286,6 @@ export function useQueryRepository() { persist: true, autoRefetch: false, } as IQueryDefinition<{ searchId: string }, IPlanet[]>, - GetFIOPlanetFees: { - key: (params: { planetNaturalId: string }) => [ - "gamedata", - "fio", - "planetfees", - params.planetNaturalId, - ], - fetchFn: async (params: { planetNaturalId: string }) => { - // FIO is a third-party service, a failing call must not - // break plan loading — fees are then unknown - try { - return await callFIOPlanetFees(params.planetNaturalId); - } catch { - return null; - } - }, - expireTime: 60_000 * config.GAME_DATA_STALE_MINUTES_PLANETS, - persist: true, - autoRefetch: false, - } as IQueryDefinition< - { planetNaturalId: string }, - IFIOProductionFeeTable | null - >, PostPlanetSearch: { key: (params: { searchData: IPlanetSearchAdvanced }) => [ "gamedata", diff --git a/src/lib/query_cache/queryRepository.types.ts b/src/lib/query_cache/queryRepository.types.ts index 13a7e6458..e2b1001ab 100644 --- a/src/lib/query_cache/queryRepository.types.ts +++ b/src/lib/query_cache/queryRepository.types.ts @@ -12,7 +12,6 @@ import { IPopulationReport, IRecipe, } from "@/features/api/gameData.types"; -import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; import { ICXEmpireJunction, @@ -100,10 +99,6 @@ export interface IQueryRepository { IPlanet[] >; GetPlanetSearchSingle: IQueryDefinition<{ searchId: string }, IPlanet[]>; - GetFIOPlanetFees: IQueryDefinition< - { planetNaturalId: string }, - IFIOProductionFeeTable | null - >; PostPlanetSearch: IQueryDefinition< { searchData: IPlanetSearchAdvanced }, IPlanet[] diff --git a/src/tests/features/planning/calculations/productionFeeCalculations.test.ts b/src/tests/features/planning/calculations/productionFeeCalculations.test.ts index bd78a4d04..1c43ff5d7 100644 --- a/src/tests/features/planning/calculations/productionFeeCalculations.test.ts +++ b/src/tests/features/planning/calculations/productionFeeCalculations.test.ts @@ -3,8 +3,10 @@ import { describe, it, expect } from "vitest"; import { calculateProductionFeeRate } from "@/features/planning/calculations/productionFeeCalculations"; // Types & Interfaces -import { IBuilding } from "@/features/api/gameData.types"; -import { IFIOProductionFeeTable } from "@/features/api/fioData.types"; +import { + IBuilding, + IPlanetProductionFee, +} from "@/features/api/gameData.types"; const fakeBuilding = { ticker: "SME", @@ -16,15 +18,12 @@ const fakeBuilding = { scientists: 0, } as unknown as IBuilding; -const fakeFees: IFIOProductionFeeTable = { - METALLURGY: { - pioneer: 50, - settler: 80, - technician: 140, - engineer: 800, - scientist: 1500, - }, -}; +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", () => { @@ -79,14 +78,23 @@ describe("productionFeeCalculations", () => { } as unknown as IBuilding; expect( - calculateProductionFeeRate(polymerPlant, { - CHEMISTRY: { pioneer: 15, settler: 12 }, - }) + 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, null)).toBe(0); + expect(calculateProductionFeeRate(fakeBuilding, undefined)).toBe(0); }); it("returns 0 without building expertise", () => { @@ -97,10 +105,10 @@ describe("productionFeeCalculations", () => { expect(calculateProductionFeeRate(noExpertise, fakeFees)).toBe(0); }); - it("returns 0 on missing industry fee table", () => { + it("returns 0 on missing industry fee entries", () => { const otherExpertise = { ...fakeBuilding, - expertise: "CHEMISTRY", + expertise: "CONSTRUCTION", } as IBuilding; expect(calculateProductionFeeRate(otherExpertise, fakeFees)).toBe( 0 From d343fc77f605d42f225c4dbdcc39ff08c42f2596 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:53:47 +0000 Subject: [PATCH 3/4] Trim fee JSDoc to house style Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PFsCfBfPMjbxDYBoh65xYC --- .../calculations/productionFeeCalculations.ts | 14 +++++++------- .../calculations/productionFeeCalculations.test.ts | 8 ++------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/features/planning/calculations/productionFeeCalculations.ts b/src/features/planning/calculations/productionFeeCalculations.ts index b4f015d8d..5f6ba8351 100644 --- a/src/features/planning/calculations/productionFeeCalculations.ts +++ b/src/features/planning/calculations/productionFeeCalculations.ts @@ -17,17 +17,15 @@ const WORKFORCE_BUILDING_FIELD_MAP: Record< }; /** - * A building's production fee rate per 24h of runtime. The rate is - * charged per building, not per employee: the workforce-weighted - * average of the planet's per-tier daily rates for the building's - * expertise, e.g. (10 x 15 + 25 x 12) / (10 + 25) = 12.9. - * @see https://handbook.apex.prosperousuniverse.com/wiki/local-rules/index.html + * 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 runtime, 0 if unknown + * @returns {number} Fee Rate per 24h, 0 if unknown */ export function calculateProductionFeeRate( building: IBuilding, @@ -45,7 +43,9 @@ export function calculateProductionFeeRate( (sum, fee) => fee.category === building.expertise ? sum + - building[WORKFORCE_BUILDING_FIELD_MAP[fee.workforce_level]] * + building[ + WORKFORCE_BUILDING_FIELD_MAP[fee.workforce_level] + ] * fee.fee_amount : sum, 0 diff --git a/src/tests/features/planning/calculations/productionFeeCalculations.test.ts b/src/tests/features/planning/calculations/productionFeeCalculations.test.ts index 1c43ff5d7..5ededf007 100644 --- a/src/tests/features/planning/calculations/productionFeeCalculations.test.ts +++ b/src/tests/features/planning/calculations/productionFeeCalculations.test.ts @@ -3,10 +3,7 @@ import { describe, it, expect } from "vitest"; import { calculateProductionFeeRate } from "@/features/planning/calculations/productionFeeCalculations"; // Types & Interfaces -import { - IBuilding, - IPlanetProductionFee, -} from "@/features/api/gameData.types"; +import { IBuilding, IPlanetProductionFee } from "@/features/api/gameData.types"; const fakeBuilding = { ticker: "SME", @@ -65,8 +62,7 @@ describe("productionFeeCalculations", () => { }); it("reproduces the APEX handbook worked example", () => { - // handbook "Local Rules": a polymer plant of 10 pioneers at 15 - // and 25 settlers at 12 pays (10 * 15 + 25 * 12) / 35 = 12.9 + // (10 * 15 + 25 * 12) / 35 = 12.9 const polymerPlant = { ticker: "POL", expertise: "CHEMISTRY", From 35a1a3b204a079958e3110aff6b927c3bd61e6f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 23:08:00 +0000 Subject: [PATCH 4/4] fix(fees): avoid dynamic object indexing flagged by Codacy Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PFsCfBfPMjbxDYBoh65xYC --- .../calculations/productionFeeCalculations.ts | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/features/planning/calculations/productionFeeCalculations.ts b/src/features/planning/calculations/productionFeeCalculations.ts index 5f6ba8351..f813ccf11 100644 --- a/src/features/planning/calculations/productionFeeCalculations.ts +++ b/src/features/planning/calculations/productionFeeCalculations.ts @@ -5,16 +5,23 @@ import { PLANET_WORKFORCE_LEVEL_TYPE, } from "@/features/api/gameData.types"; -const WORKFORCE_BUILDING_FIELD_MAP: Record< - PLANET_WORKFORCE_LEVEL_TYPE, - "pioneers" | "settlers" | "technicians" | "engineers" | "scientists" -> = { - PIONEER: "pioneers", - SETTLER: "settlers", - TECHNICIAN: "technicians", - ENGINEER: "engineers", - SCIENTIST: "scientists", -}; +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 @@ -33,19 +40,19 @@ export function calculateProductionFeeRate( ): number { if (!fees || building.expertise === null) return 0; - const workers: number = Object.values(WORKFORCE_BUILDING_FIELD_MAP).reduce( - (sum, field) => sum + building[field], - 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 + - building[ - WORKFORCE_BUILDING_FIELD_MAP[fee.workforce_level] - ] * + getWorkforceAmount(building, fee.workforce_level) * fee.fee_amount : sum, 0