diff --git a/src/components/Factory/Canvas/GameCanvas.tsx b/src/components/Factory/Canvas/GameCanvas.tsx index 64f379daf..ac68efaeb 100644 --- a/src/components/Factory/Canvas/GameCanvas.tsx +++ b/src/components/Factory/Canvas/GameCanvas.tsx @@ -15,7 +15,9 @@ import { useEffect, useRef, useState } from "react"; import { BlockStack } from "@/components/ui/layout"; +import type { GlobalResources } from "../data/resources"; import { setup } from "../data/setup"; +import { createBuildingNode } from "../objects/buildings/createBuildingNode"; import { useGlobalResources } from "../providers/GlobalResourcesProvider"; import { processDay } from "../simulation/processDay"; import type { DayStatistics } from "../types/statistics"; @@ -36,7 +38,7 @@ const edgeTypes: Record> = { interface GameCanvasProps extends ReactFlowProps { onDayAdvance?: ( - globalOutputs: Record, + globalResources: GlobalResources, statistics: DayStatistics, ) => void; triggerAdvance?: number; @@ -59,7 +61,13 @@ const GameCanvas = ({ const prevTriggerRef = useRef(0); useEffect(() => { - setNodes(setup.buildings); + const newNodes = setup.buildings?.map((building) => + createBuildingNode(building.type, building.position), + ); + + if (newNodes) { + setNodes(newNodes); + } }, [setNodes]); // Process day advancement @@ -69,7 +77,7 @@ const GameCanvas = ({ prevTriggerRef.current = triggerAdvance; - const { updatedNodes, globalOutputs, statistics } = processDay( + const { updatedNodes, statistics } = processDay( nodes, edges, currentDay, @@ -77,8 +85,8 @@ const GameCanvas = ({ ); setNodes(updatedNodes); - updateResources(globalOutputs); - onDayAdvance?.(globalOutputs, statistics); + updateResources(statistics.global.earned); + onDayAdvance?.(statistics.global.resources, statistics); }, [ triggerAdvance, currentDay, diff --git a/src/components/Factory/Canvas/Handles/BuildingInput.tsx b/src/components/Factory/Canvas/Handles/BuildingInput.tsx index 730e105ac..7ca13af1c 100644 --- a/src/components/Factory/Canvas/Handles/BuildingInput.tsx +++ b/src/components/Factory/Canvas/Handles/BuildingInput.tsx @@ -11,8 +11,8 @@ import { cn } from "@/lib/utils"; import { RESOURCE_COLORS } from "../../data/resources"; import { - type Building, type BuildingInput as BuildingInputConfig, + type BuildingInstance, } from "../../types/buildings"; import { isLightColor } from "../../utils/color"; import { layoutHandleAtPosition } from "./utils"; @@ -25,7 +25,7 @@ const BuildingInput = ({ groupIndex, totalInGroup, }: { - building: Building; + building: BuildingInstance; input: BuildingInputConfig; selected?: boolean; index: number; @@ -34,6 +34,10 @@ const BuildingInput = ({ }) => { const { resource, position } = input; + if (!position) { + return null; + } + return ( { const { resource, position } = output; + if (!position) { + return null; + } + return ( { if (!selected) return; const handleKeyPress = (event: KeyboardEvent) => { - if (!isBuildingData(data)) return; + if (!isBuildingInstance(data)) return; if (event.key === "r" || event.key === "R") { event.preventDefault(); @@ -42,16 +46,17 @@ const Building = ({ id, data, selected }: NodeProps) => { return () => window.removeEventListener("keydown", handleKeyPress); }, [selected, id, data, updateNodeData, updateNodeInternals]); - // Handle context panel - update on data changes useEffect(() => { if (selected) { - // Get the latest node data from React Flow - const currentNode = getNode(id); - const currentData = currentNode?.data || data; + const buildingInstance = getBuildingInstance(data); - if (!isBuildingData(currentData)) return; + if (!buildingInstance) { + setContent(); + setContextPanelOpen(true); + return; + } - setContent(); + setContent(); setContextPanelOpen(true); } @@ -60,26 +65,22 @@ const Building = ({ id, data, selected }: NodeProps) => { clearContent(); } }; - }, [ - selected, - data, - id, - getNode, - setContent, - clearContent, - setContextPanelOpen, - ]); - - if (!isBuildingData(data)) { - return ( -
-
Invalid Building
-
Data is not valid
-
- ); + }, [selected, data, getNode, setContent, clearContent, setContextPanelOpen]); + + const instance = getBuildingInstance(data); + + if (!instance) { + return ; } - const { icon, name, description, color, inputs = [], outputs = [] } = data; + const { + icon, + name, + description, + color, + inputs = [], + outputs = [], + } = instance; // Calculate position counts const inputCounts = countBuildingIO(inputs); @@ -96,7 +97,7 @@ const Building = ({ id, data, selected }: NodeProps) => { {inputs.map((input, globalIndex) => { if (!input.position) return null; - const isGlobal = RESOURCES[input.resource]?.global; + const isGlobal = isGlobalResource(input.resource); if (isGlobal) return; const posIndex = inputIndexAtPosition[input.position] || 0; @@ -105,7 +106,7 @@ const Building = ({ id, data, selected }: NodeProps) => { return ( { {outputs.map((output, globalIndex) => { if (!output.position) return null; - const isGlobal = RESOURCES[output.resource]?.global; + const isGlobal = isGlobalResource(output.resource); if (isGlobal) return; const posIndex = outputIndexAtPosition[output.position] || 0; @@ -139,7 +140,7 @@ const Building = ({ id, data, selected }: NodeProps) => { return ( { export default Building; -function countBuildingIO(ios: (BuildingInput | BuildingOutput)[]) { +function countBuildingIO(ios: (BuildingInputType | BuildingOutputType)[]) { const counts: Record = {}; ios.forEach((io) => { if (!io.position) return; @@ -163,3 +164,10 @@ function countBuildingIO(ios: (BuildingInput | BuildingOutput)[]) { }); return counts; } + +const InvalidBuildingNode = () => ( +
+
Invalid Building
+
Data is not valid
+
+); diff --git a/src/components/Factory/Canvas/callbacks/onDrop.ts b/src/components/Factory/Canvas/callbacks/onDrop.ts index 9683ddf30..c5c1218e8 100644 --- a/src/components/Factory/Canvas/callbacks/onDrop.ts +++ b/src/components/Factory/Canvas/callbacks/onDrop.ts @@ -1,9 +1,7 @@ import type { Node, ReactFlowInstance } from "@xyflow/react"; import type { DragEvent } from "react"; -import type { Building } from "../../types/buildings"; - -let nodeIdCounter = 0; +import { createBuildingNode } from "../../objects/buildings/createBuildingNode"; export const createOnDrop = ( reactFlowInstance: ReactFlowInstance | undefined, @@ -14,11 +12,12 @@ export const createOnDrop = ( if (!reactFlowInstance) return; - const buildingData = event.dataTransfer.getData("application/reactflow"); - if (!buildingData) return; + const droppedBuildingData = event.dataTransfer.getData( + "application/reactflow", + ); try { - const { building } = JSON.parse(buildingData) as { building: Building }; + const { buildingType } = JSON.parse(droppedBuildingData); const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, @@ -32,18 +31,7 @@ export const createOnDrop = ( position.y -= offsetY; } - const newNode: Node = { - id: `${building.id}-${nodeIdCounter++}`, - type: "building", - position, - data: { - ...building, - label: building.name, - }, - draggable: true, - deletable: true, - selectable: true, - }; + const newNode = createBuildingNode(buildingType, position); setNodes((nds) => [...nds, newNode]); } catch (error) { diff --git a/src/components/Factory/Context/Building/ProductionMethodSection.tsx b/src/components/Factory/Context/Building/ProductionMethodSection.tsx index 668aa705b..68bcb09ff 100644 --- a/src/components/Factory/Context/Building/ProductionMethodSection.tsx +++ b/src/components/Factory/Context/Building/ProductionMethodSection.tsx @@ -4,8 +4,8 @@ import { Progress } from "@/components/ui/progress"; import { Text } from "@/components/ui/typography"; import { pluralize } from "@/utils/string"; -import { RESOURCES } from "../../data/resources"; -import type { ProductionMethod, ProductionState } from "../../types/buildings"; +import { isGlobalResource, RESOURCES } from "../../data/resources"; +import type { ProductionMethod, ProductionState } from "../../types/production"; interface ProductionMethodSectionProps { productionMethod?: ProductionMethod; @@ -80,7 +80,7 @@ export const ProductionMethodSection = ({ Outputs: {productionMethod.outputs.map((output, idx) => { - if (RESOURCES[output.resource].global) { + if (isGlobalResource(output.resource)) { return ( diff --git a/src/components/Factory/Context/Building/StockpileSection.tsx b/src/components/Factory/Context/Building/StockpileSection.tsx index a0714e62d..79851fe50 100644 --- a/src/components/Factory/Context/Building/StockpileSection.tsx +++ b/src/components/Factory/Context/Building/StockpileSection.tsx @@ -1,15 +1,16 @@ import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; -import { RESOURCES } from "../../data/resources"; +import { isGlobalResource, RESOURCES } from "../../data/resources"; import { useStatistics } from "../../providers/StatisticProvider"; -import type { Building, Stockpile } from "../../types/buildings"; +import type { BuildingInstance, Stockpile } from "../../types/buildings"; +import type { ResourceType } from "../../types/resources"; import type { StockpileChange } from "../../types/statistics"; interface StockpileSectionProps { nodeId: string; stockpile: Stockpile[]; - building: Building; + building: BuildingInstance; } export const StockpileSection = ({ @@ -20,8 +21,8 @@ export const StockpileSection = ({ const { lastDayStats } = useStatistics(); const statistics = lastDayStats?.buildings.get(nodeId); - const hasGlobalOutputs = building.productionMethod?.outputs.some( - (output) => RESOURCES[output.resource]?.global, + const hasGlobalOutputs = building.productionMethod?.outputs.some((output) => + isGlobalResource(output.resource), ); if (stockpile.length === 0) { @@ -87,7 +88,10 @@ export const StockpileSection = ({ Total: {stock.amount} / {stock.maxAmount} - • Expected Value: {RESOURCES.money.icon} {totalValue} + • + + + Expected Value: {RESOURCES.money.icon} {totalValue} @@ -122,8 +126,9 @@ export const StockpileSection = ({ {hasGlobalOutputs && statistics?.produced && ( {Object.entries(statistics.produced).map(([resource, amount]) => ( - - Last Day: +{amount} {RESOURCES[resource]?.icon} + + Previous Day: +{amount}{" "} + {RESOURCES[resource as ResourceType]?.icon} ))} diff --git a/src/components/Factory/Context/BuildingContext.tsx b/src/components/Factory/Context/BuildingContext.tsx index 106b9785b..baa0cfc02 100644 --- a/src/components/Factory/Context/BuildingContext.tsx +++ b/src/components/Factory/Context/BuildingContext.tsx @@ -1,7 +1,7 @@ import { BlockStack } from "@/components/ui/layout"; import { Separator } from "@/components/ui/separator"; -import type { Building } from "../types/buildings"; +import type { BuildingInstance } from "../types/buildings"; import { BuildingDescription } from "./Building/BuildingDescription"; import { ConnectionsSection } from "./Building/ConnectionsSection"; import { ProductionMethodSection } from "./Building/ProductionMethodSection"; @@ -9,7 +9,7 @@ import { StockpileSection } from "./Building/StockpileSection"; import { ContextHeader } from "./shared/ContextHeader"; interface BuildingContextProps { - building: Building; + building: BuildingInstance; nodeId: string; } diff --git a/src/components/Factory/FactoryGame.tsx b/src/components/Factory/FactoryGame.tsx index babc3a9f8..ca7fa3b8d 100644 --- a/src/components/Factory/FactoryGame.tsx +++ b/src/components/Factory/FactoryGame.tsx @@ -9,6 +9,7 @@ import { ContextPanelProvider } from "@/providers/ContextPanelProvider"; import GameCanvas from "./Canvas/GameCanvas"; import GameControls from "./Controls/GameControls"; +import type { GlobalResources } from "./data/resources"; import { GlobalResourcesProvider } from "./providers/GlobalResourcesProvider"; import { StatisticsProvider } from "./providers/StatisticProvider"; import GameSidebar from "./Sidebar/GameSidebar"; @@ -45,7 +46,10 @@ const FactoryGame = () => { setAdvanceTrigger((prev) => prev + 1); }; - const handleDayAdvance = (statistics: DayStatistics) => { + const handleDayAdvance = ( + _globalResources: GlobalResources, + statistics: DayStatistics, + ) => { setLastDayStats(statistics); setStatsHistory((prev) => [...prev, statistics]); }; diff --git a/src/components/Factory/Sidebar/BuildingItem.tsx b/src/components/Factory/Sidebar/BuildingItem.tsx index 54bf444b6..902b6e4a5 100644 --- a/src/components/Factory/Sidebar/BuildingItem.tsx +++ b/src/components/Factory/Sidebar/BuildingItem.tsx @@ -4,17 +4,19 @@ import { InlineStack } from "@/components/ui/layout"; import { cn } from "@/lib/utils"; import { RESOURCES } from "../data/resources"; -import type { Building } from "../types/buildings"; +import { type BuildingType, getBuildingType } from "../types/buildings"; interface BuildingItemProps { - building: Building; + buildingType: BuildingType; } -const BuildingItem = ({ building }: BuildingItemProps) => { +const BuildingItem = ({ buildingType }: BuildingItemProps) => { + const building = getBuildingType(buildingType); + const onDragStart = (event: DragEvent) => { event.dataTransfer.setData( "application/reactflow", - JSON.stringify({ building }), + JSON.stringify({ buildingType }), ); event.dataTransfer.setData( @@ -36,8 +38,6 @@ const BuildingItem = ({ building }: BuildingItemProps) => { )} draggable onDragStart={onDragStart} - data-testid="building-item" - data-building-id={building.id} > {building.icon} diff --git a/src/components/Factory/Sidebar/Buildings.tsx b/src/components/Factory/Sidebar/Buildings.tsx index 0430bd23e..62449a1e6 100644 --- a/src/components/Factory/Sidebar/Buildings.tsx +++ b/src/components/Factory/Sidebar/Buildings.tsx @@ -1,7 +1,7 @@ import { BlockStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; -import { BUILDINGS } from "../data/buildings"; +import { BUILDING_TYPES } from "../types/buildings"; import BuildingItem from "./BuildingItem"; const Buildings = () => { @@ -9,8 +9,8 @@ const Buildings = () => { Buildings - {BUILDINGS.map((building) => ( - + {BUILDING_TYPES.map((buildingType) => ( + ))} diff --git a/src/components/Factory/data/buildings.ts b/src/components/Factory/data/buildings.ts index f02052cc5..f7c3d312f 100644 --- a/src/components/Factory/data/buildings.ts +++ b/src/components/Factory/data/buildings.ts @@ -1,296 +1,229 @@ -import { Position } from "@xyflow/react"; +import type { BuildingClass } from "../types/buildings"; -import type { Building } from "../types/buildings"; - -export const BUILDINGS: Building[] = [ - { - id: "marketplace", +export const BUILDINGS: Record = { + marketplace: { name: "Marketplace", icon: "🏛️", description: "Sell goods here", cost: 0, color: "#FBBF24", - inputs: [ - { resource: "any", position: Position.Left }, - { resource: "any", position: Position.Right }, - { resource: "any", position: Position.Top }, - { resource: "any", position: Position.Bottom }, + productionMethods: [ + { + name: "Trading", + inputs: [{ resource: "any", amount: 100, nodes: 4 }], + outputs: [{ resource: "money", amount: 1 }], + days: 1, + }, ], - productionMethod: { - name: "Trading", - inputs: [{ resource: "any", amount: 1 }], - outputs: [{ resource: "money", amount: 1 }], - days: 1, - }, - stockpile: [{ resource: "any", amount: 0, maxAmount: 1000 }], }, - { - id: "woodcutter", + woodcutter: { name: "Woodcutter's Camp", icon: "🪓", description: "Produces wood", cost: 0, color: "#A0522D", - outputs: [{ resource: "wood", position: Position.Right }], - productionMethod: { - name: "Hand Axes", - inputs: [], - outputs: [{ resource: "wood", amount: 10 }], - days: 1, - }, - stockpile: [{ resource: "wood", amount: 0, maxAmount: 100 }], + productionMethods: [ + { + name: "Hand Axes", + inputs: [], + outputs: [{ resource: "wood", amount: 10 }], + days: 1, + }, + ], }, - { - id: "quarry", + quarry: { name: "Quarry", icon: "⛏️", description: "Produces stone", cost: 0, color: "#708090", - outputs: [{ resource: "stone", position: Position.Right }], - productionMethod: { - name: "Hand Axes", - inputs: [], - outputs: [{ resource: "stone", amount: 5 }], - days: 1, - }, - stockpile: [{ resource: "stone", amount: 0, maxAmount: 50 }], + productionMethods: [ + { + name: "Hand Axes", + inputs: [], + outputs: [{ resource: "stone", amount: 5 }], + days: 1, + }, + ], }, - { - id: "farm", + farm: { name: "Farm", icon: "🌾", description: "Produces wheat", cost: 0, color: "#228B22", - outputs: [{ resource: "wheat", position: Position.Right }], - productionMethod: { - name: "Wheat", - inputs: [], - outputs: [{ resource: "wheat", amount: 60 }], - days: 3, - }, - stockpile: [{ resource: "wheat", amount: 0, maxAmount: 600 }], + productionMethods: [ + { + name: "Wheat", + inputs: [], + outputs: [{ resource: "wheat", amount: 60 }], + days: 3, + }, + ], }, - { - id: "sawmill", + sawmill: { name: "Sawmill", icon: "🏭", description: "Turns wood into planks", cost: 0, color: "#D2691E", - inputs: [ - { resource: "wood", position: Position.Left }, - { resource: "wood", position: Position.Left }, - ], - outputs: [{ resource: "planks", position: Position.Right }], - productionMethod: { - name: "Sandpaper and Hand Saw", - inputs: [{ resource: "wood", amount: 40 }], - outputs: [{ resource: "planks", amount: 20 }], - days: 2, - }, - stockpile: [ - { resource: "wood", amount: 0, maxAmount: 400 }, - { resource: "planks", amount: 0, maxAmount: 200 }, + productionMethods: [ + { + name: "Sandpaper and Hand Saw", + inputs: [{ resource: "wood", amount: 40, nodes: 2 }], + outputs: [{ resource: "planks", amount: 20 }], + days: 2, + }, ], }, - { - id: "papermill", + papermill: { name: "Papermill", icon: "🏭", description: "Turns wood into paper", cost: 0, color: "#6A5ACD", - inputs: [{ resource: "wood", position: Position.Left }], - outputs: [ - { resource: "paper", position: Position.Right }, - { resource: "paper", position: Position.Right }, - ], - productionMethod: { - name: "Pulp and Press", - inputs: [{ resource: "wood", amount: 20 }], - outputs: [{ resource: "paper", amount: 50 }], - days: 3, - }, - stockpile: [ - { resource: "wood", amount: 0, maxAmount: 200 }, - { resource: "paper", amount: 0, maxAmount: 500 }, + productionMethods: [ + { + name: "Pulp and Press", + inputs: [{ resource: "wood", amount: 20 }], + outputs: [{ resource: "paper", amount: 50 }], + days: 3, + }, ], }, - { - id: "pasture", + pasture: { name: "Pasture", icon: "🐄", description: "Turns wheat into livestock", cost: 0, color: "#A52A2A", - inputs: [{ resource: "wheat", position: Position.Left }], - outputs: [{ resource: "livestock", position: Position.Right }], - productionMethod: { - name: "Free Range Grazing", - inputs: [{ resource: "wheat", amount: 25 }], - outputs: [{ resource: "livestock", amount: 1 }], - days: 10, - }, - stockpile: [ - { resource: "wheat", amount: 0, maxAmount: 250 }, - { resource: "livestock", amount: 0, maxAmount: 10 }, + productionMethods: [ + { + name: "Free Range Grazing", + inputs: [{ resource: "wheat", amount: 25 }], + outputs: [{ resource: "livestock", amount: 1 }], + days: 10, + }, ], }, - { - id: "butchery", + butchery: { name: "Butchery", icon: "🔪", description: "Processes livestock", cost: 0, color: "#8B0000", - inputs: [{ resource: "livestock", position: Position.Left }], - outputs: [ - { resource: "meat", position: Position.Right }, - { resource: "leather", position: Position.Right }, - ], - productionMethod: { - name: "Carving Knives", - inputs: [{ resource: "livestock", amount: 1 }], - outputs: [ - { resource: "meat", amount: 6 }, - { resource: "leather", amount: 2 }, - ], - days: 3, - }, - stockpile: [ - { resource: "livestock", amount: 0, maxAmount: 10 }, - { resource: "meat", amount: 0, maxAmount: 60 }, - { resource: "leather", amount: 0, maxAmount: 20 }, + productionMethods: [ + { + name: "Carving Knives", + inputs: [{ resource: "livestock", amount: 1 }], + outputs: [ + { resource: "meat", amount: 6 }, + { resource: "leather", amount: 2 }, + ], + days: 3, + }, ], }, - { - id: "bookbinder", + bookbinder: { name: "Bookbinder", icon: "📚", description: "Produces books", cost: 0, color: "#4B0082", - inputs: [ - { resource: "paper", position: Position.Left }, - { resource: "leather", position: Position.Left }, - ], - outputs: [{ resource: "books", position: Position.Right }], - productionMethod: { - name: "Bookbinding", - inputs: [ - { resource: "paper", amount: 20 }, - { resource: "leather", amount: 2 }, - ], - outputs: [{ resource: "books", amount: 1 }], - days: 5, - }, - stockpile: [ - { resource: "paper", amount: 0, maxAmount: 200 }, - { resource: "leather", amount: 0, maxAmount: 20 }, - { resource: "books", amount: 0, maxAmount: 10 }, + productionMethods: [ + { + name: "Bookbinding", + inputs: [ + { resource: "paper", amount: 20 }, + { resource: "leather", amount: 2 }, + ], + outputs: [{ resource: "books", amount: 1 }], + days: 5, + }, ], }, - { - id: "library", + library: { name: "Library", icon: "🏛️", description: "Centre of knowledge", cost: 0, color: "#483D8B", - inputs: [ - { resource: "paper", position: Position.Top }, - { resource: "books", position: Position.Left }, - ], - productionMethod: { - name: "Research", - inputs: [ - { resource: "paper", amount: 10 }, - { resource: "books", amount: 1 }, - ], - outputs: [{ resource: "knowledge", amount: 1 }], - days: 1, - }, - stockpile: [ - { resource: "paper", amount: 0, maxAmount: 100 }, - { resource: "books", amount: 0, maxAmount: 10 }, + productionMethods: [ + { + name: "Writing", + inputs: [{ resource: "paper", amount: 40, nodes: 4 }], + outputs: [{ resource: "knowledge", amount: 1 }], + days: 1, + }, + { + name: "Reading", + inputs: [{ resource: "books", amount: 10, nodes: 2 }], + outputs: [{ resource: "knowledge", amount: 1 }], + days: 1, + }, ], }, - { - id: "mill", + mill: { name: "Mill", icon: "🏭", description: "Grinds wheat into flour", cost: 0, color: "#DAA520", - inputs: [ - { resource: "wheat", position: Position.Left }, - { resource: "wheat", position: Position.Left }, - ], - outputs: [{ resource: "flour", position: Position.Right }], - productionMethod: { - name: "Grinding Stones", - inputs: [{ resource: "wheat", amount: 40 }], - outputs: [{ resource: "flour", amount: 20 }], - days: 4, - }, - stockpile: [ - { resource: "wheat", amount: 0, maxAmount: 400 }, - { resource: "flour", amount: 0, maxAmount: 200 }, + productionMethods: [ + { + name: "Grinding Stones", + inputs: [{ resource: "wheat", amount: 40 }], + outputs: [{ resource: "flour", amount: 20 }], + days: 4, + }, ], }, - { - id: "kiln", + kiln: { name: "Kiln", icon: "🏭", description: "Burns wood into coal", cost: 0, color: "#36454F", - inputs: [ - { resource: "wood", position: Position.Left }, - { resource: "wood", position: Position.Left }, - ], - outputs: [ - { resource: "coal", position: Position.Right }, - { resource: "coal", position: Position.Right }, - ], - productionMethod: { - name: "Charcoal Burning", - inputs: [{ resource: "wood", amount: 50 }], - outputs: [{ resource: "coal", amount: 50 }], - days: 2, - }, - stockpile: [ - { resource: "wood", amount: 0, maxAmount: 50 }, - { resource: "coal", amount: 0, maxAmount: 50 }, + productionMethods: [ + { + name: "Charcoal Burning", + inputs: [{ resource: "wood", amount: 50 }], + outputs: [{ resource: "coal", amount: 50 }], + days: 2, + }, ], }, - { - id: "bakery", + bakery: { name: "Bakery", icon: "🍞", description: "Bakes flour into bread", cost: 0, color: "#F5DEB3", - inputs: [ - { resource: "flour", position: Position.Left }, - { resource: "coal", position: Position.Top }, + productionMethods: [ + { + name: "Oven Baking", + inputs: [ + { resource: "flour", amount: 10 }, + { resource: "coal", amount: 10 }, + ], + outputs: [{ resource: "bread", amount: 10 }], + days: 2, + }, ], - outputs: [{ resource: "bread", position: Position.Right }], - productionMethod: { - name: "Oven Baking", - inputs: [ - { resource: "flour", amount: 10 }, - { resource: "coal", amount: 10 }, - ], - outputs: [{ resource: "bread", amount: 10 }], - days: 2, - }, - stockpile: [ - { resource: "flour", amount: 0, maxAmount: 100 }, - { resource: "bread", amount: 0, maxAmount: 100 }, - { resource: "coal", amount: 0, maxAmount: 100 }, + }, + bank: { + name: "Bank", + icon: "🏦", + description: "Generates money over time", + cost: 100, + color: "#FFD700", + productionMethods: [ + { + name: "Minting", + inputs: [], + outputs: [{ resource: "money", amount: 5 }], + days: 3, + }, ], }, -]; +} as const satisfies Record; diff --git a/src/components/Factory/data/resources.ts b/src/components/Factory/data/resources.ts index 1745a7bd1..b7be0b368 100644 --- a/src/components/Factory/data/resources.ts +++ b/src/components/Factory/data/resources.ts @@ -38,7 +38,7 @@ export const RESOURCE_VALUES: Record = { any: 1, }; -export const RESOURCES: Record = { +export const RESOURCES = { money: { name: "Money", description: "You need money to pay for things!", @@ -82,7 +82,6 @@ export const RESOURCES: Record = { color: RESOURCE_COLORS.wheat, icon: "🌾", value: RESOURCE_VALUES.wheat, - global: true, }, planks: { name: "Planks", @@ -155,4 +154,33 @@ export const RESOURCES: Record = { icon: "❓", value: RESOURCE_VALUES.any, }, -}; +} as const satisfies Record; + +// Extract global resource types dynamically +export type GlobalResourceType = { + [K in keyof typeof RESOURCES]: (typeof RESOURCES)[K] extends { global: true } + ? K + : never; +}[keyof typeof RESOURCES]; + +export type GlobalResources = Record; + +// Helper to check if a resource is global at runtime +export function isGlobalResource( + resourceType: ResourceType, +): resourceType is GlobalResourceType { + const resource = RESOURCES[resourceType]; + return ( + resource !== undefined && "global" in resource && resource.global === true + ); +} + +// Get all global resource keys +export const GLOBAL_RESOURCE_KEYS = ( + Object.keys(RESOURCES) as ResourceType[] +).filter((key): key is GlobalResourceType => { + const resource = RESOURCES[key]; + return ( + resource !== undefined && "global" in resource && resource.global === true + ); +}); diff --git a/src/components/Factory/data/setup.ts b/src/components/Factory/data/setup.ts index 41344758f..d94f76057 100644 --- a/src/components/Factory/data/setup.ts +++ b/src/components/Factory/data/setup.ts @@ -1,22 +1,19 @@ -import { type Node } from "@xyflow/react"; +import type { XYPosition } from "@xyflow/react"; -import { BUILDINGS } from "./buildings"; +import type { BuildingType } from "../types/buildings"; -// Initial marketplace node at the center -const MARKETPLACE_NODE: Node = { - id: "starting-marketplace", - type: "building", - position: { x: 0, y: 0 }, - data: { - ...BUILDINGS.find((b) => b.id === "marketplace"), - }, - draggable: true, - deletable: false, - selectable: true, +type BuildingSetup = { + type: BuildingType; + position: XYPosition; }; +interface setup { + buildings?: BuildingSetup[]; +} -const buildings = [MARKETPLACE_NODE]; +const buildings: BuildingSetup[] = [ + { type: "marketplace", position: { x: 0, y: 0 } }, +]; -export const setup = { +export const setup: setup = { buildings, }; diff --git a/src/components/Factory/hooks/useGameState.ts b/src/components/Factory/hooks/useGameState.ts index 20f68bf15..db725882e 100644 --- a/src/components/Factory/hooks/useGameState.ts +++ b/src/components/Factory/hooks/useGameState.ts @@ -1,7 +1,7 @@ import type { Edge, Node } from "@xyflow/react"; import { useState } from "react"; -import { isBuildingData, type Stockpile } from "../types/buildings"; +import { isBuildingInstance, type Stockpile } from "../types/buildings"; import type { GameState } from "../types/game"; export const useGameState = (initialNodes: Node[], initialEdges: Edge[]) => { @@ -27,7 +27,7 @@ export const useGameState = (initialNodes: Node[], initialEdges: Edge[]) => { const nodes = prev.nodes.map((node) => { if (node.id !== nodeId) return node; - if (!isBuildingData(node.data)) { + if (!isBuildingInstance(node.data)) { console.error("Node data is not a valid building:", node.data); return node; } diff --git a/src/components/Factory/objects/buildings/createBuildingInstance.ts b/src/components/Factory/objects/buildings/createBuildingInstance.ts new file mode 100644 index 000000000..712cd5f42 --- /dev/null +++ b/src/components/Factory/objects/buildings/createBuildingInstance.ts @@ -0,0 +1,167 @@ +import { Position } from "@xyflow/react"; + +import { isGlobalResource } from "../../data/resources"; +import { + type BuildingInput, + type BuildingInstance, + type BuildingOutput, + type BuildingType, + getBuildingType, + type Stockpile, +} from "../../types/buildings"; +import type { ResourceType } from "../../types/resources"; + +const STOCKPILE_MULTIPLIER = 10; + +/** + * Distributes handles evenly across all four sides + */ +function distributeHandlesAcrossSides(count: number): Position[] { + const positions: Position[] = []; + const sides = [Position.Left, Position.Top, Position.Right, Position.Bottom]; + + for (let i = 0; i < count; i++) { + positions.push(sides[i % 4]); + } + + return positions; +} + +/** + * Creates a runtime building instance from a building definition + * @param building - The building template + * @param productionMethodIndex - Which production method to use (default: 0) + */ +export function createBuildingInstance( + buildingType: BuildingType, + productionMethodIndex: number = 0, +): BuildingInstance { + const building = getBuildingType(buildingType); + + const productionMethod = building.productionMethods[productionMethodIndex]; + + if (!productionMethod) { + throw new Error( + `No production method at index ${productionMethodIndex} for building ${buildingType}`, + ); + } + + // Check if we have any non-global outputs + const hasNonGlobalOutputs = productionMethod.outputs.some( + (output) => !isGlobalResource(output.resource), + ); + + // Check if we have any non-global inputs + const hasNonGlobalInputs = productionMethod.inputs.some( + (input) => !isGlobalResource(input.resource), + ); + + // Generate inputs from production method + const inputs: BuildingInput[] = []; + + productionMethod.inputs.forEach((input) => { + // Skip global resources - they don't need physical inputs + if (isGlobalResource(input.resource)) return; + + const nodeCount = input.nodes ?? 1; + + // If no outputs exist (or all are global), spread inputs across all sides + const shouldSpread = !hasNonGlobalOutputs; + + if (shouldSpread && nodeCount > 1) { + const positions = distributeHandlesAcrossSides(nodeCount); + positions.forEach((position) => { + inputs.push({ + resource: input.resource, + position, + }); + }); + } else { + // Put all inputs on the same side (left) + for (let i = 0; i < nodeCount; i++) { + inputs.push({ + resource: input.resource, + position: Position.Left, + }); + } + } + }); + + // Generate outputs from production method + const outputs: BuildingOutput[] = []; + + productionMethod.outputs.forEach((output) => { + // Skip global resources - they don't need physical outputs + if (isGlobalResource(output.resource)) return; + + const nodeCount = output.nodes ?? 1; + + // If no inputs exist (or all are global), spread outputs across all sides + const shouldSpread = !hasNonGlobalInputs; + + if (shouldSpread && nodeCount > 1) { + const positions = distributeHandlesAcrossSides(nodeCount); + positions.forEach((position) => { + outputs.push({ + resource: output.resource, + position, + }); + }); + } else { + // Put all outputs on the same side (right) + for (let i = 0; i < nodeCount; i++) { + outputs.push({ + resource: output.resource, + position: Position.Right, + }); + } + } + }); + + // Generate stockpiles from production method + const stockpile: Stockpile[] = []; + const stockpileMap = new Map(); + + productionMethod.inputs.forEach((input) => { + if (!stockpileMap.has(input.resource)) { + stockpileMap.set(input.resource, input.amount * STOCKPILE_MULTIPLIER); + } + }); + + productionMethod.outputs.forEach((output) => { + if (isGlobalResource(output.resource)) return; + + if (!stockpileMap.has(output.resource)) { + stockpileMap.set(output.resource, output.amount * STOCKPILE_MULTIPLIER); + } + }); + + stockpileMap.forEach((maxAmount, resource) => { + stockpile.push({ + resource, + amount: 0, + maxAmount, + ...(resource === "any" && { breakdown: new Map() }), + }); + }); + + const id = `${buildingType}-${crypto.randomUUID()}`; + + return { + id, + type: buildingType, + name: building.name, + icon: building.icon, + description: building.description, + cost: building.cost, + color: building.color, + inputs, + outputs, + stockpile, + productionMethod, + productionState: { + progress: 0, + status: "idle", + }, + }; +} diff --git a/src/components/Factory/objects/buildings/createBuildingNode.ts b/src/components/Factory/objects/buildings/createBuildingNode.ts new file mode 100644 index 000000000..96619c402 --- /dev/null +++ b/src/components/Factory/objects/buildings/createBuildingNode.ts @@ -0,0 +1,27 @@ +import type { Node, XYPosition } from "@xyflow/react"; + +import type { BuildingType } from "../../types/buildings"; +import { createBuildingInstance } from "./createBuildingInstance"; + +export const createBuildingNode = ( + buildingType: BuildingType, + position: XYPosition, + productionMethodIndex: number = 0, +): Node => { + const buildingInstance = createBuildingInstance( + buildingType, + productionMethodIndex, + ); + + const newNode: Node = { + id: buildingInstance.id, + type: "building", + position, + data: { buildingInstance: buildingInstance }, + draggable: true, + deletable: true, + selectable: true, + }; + + return newNode; +}; diff --git a/src/components/Factory/providers/GlobalResourcesProvider.tsx b/src/components/Factory/providers/GlobalResourcesProvider.tsx index f840e414b..f10a71e13 100644 --- a/src/components/Factory/providers/GlobalResourcesProvider.tsx +++ b/src/components/Factory/providers/GlobalResourcesProvider.tsx @@ -1,14 +1,17 @@ import { createContext, type ReactNode, useContext, useState } from "react"; -import { RESOURCES } from "../data/resources"; -import type { ResourceType } from "../types/resources"; +import { + GLOBAL_RESOURCE_KEYS, + type GlobalResources, + type GlobalResourceType, +} from "../data/resources"; interface GlobalResourcesContextType { - resources: Record; - updateResources: (updates: Record) => void; - setResource: (resourceType: ResourceType, amount: number) => void; - addResource: (resourceType: ResourceType, amount: number) => void; - getResource: (resourceType: ResourceType) => number; + resources: GlobalResources; + updateResources: (updates: Partial) => void; + setResource: (resourceType: GlobalResourceType, amount: number) => void; + addResource: (resourceType: GlobalResourceType, amount: number) => void; + getResource: (resourceType: GlobalResourceType) => number; } const GlobalResourcesContext = createContext< @@ -23,39 +26,43 @@ export const GlobalResourcesProvider = ({ children, }: GlobalResourcesProviderProps) => { // Initialize all global resources to 0 - const globalResourceTypes = Object.entries(RESOURCES) - .filter(([_, resource]) => resource.global) - .map(([type]) => type); - - const [resources, setResources] = useState>( - Object.fromEntries(globalResourceTypes.map((type) => [type, 0])), + const [resources, setResources] = useState( + Object.fromEntries(GLOBAL_RESOURCE_KEYS.map((type) => [type, 0])) as Record< + GlobalResourceType, + number + >, ); - const updateResources = (updates: Record) => { + const updateResources = (updates: Partial) => { setResources((prev) => { const updated = { ...prev }; - Object.entries(updates).forEach(([resource, amount]) => { - updated[resource] = (updated[resource] || 0) + amount; + + (Object.keys(updates) as GlobalResourceType[]).forEach((resource) => { + const amount = updates[resource]; + if (amount !== undefined) { + updated[resource] = (updated[resource] || 0) + amount; + } }); + return updated; }); }; - const setResource = (resourceType: ResourceType, amount: number) => { + const setResource = (resourceType: GlobalResourceType, amount: number) => { setResources((prev) => ({ ...prev, [resourceType]: amount, })); }; - const addResource = (resourceType: ResourceType, amount: number) => { + const addResource = (resourceType: GlobalResourceType, amount: number) => { setResources((prev) => ({ ...prev, [resourceType]: (prev[resourceType] || 0) + amount, })); }; - const getResource = (resourceType: ResourceType): number => { + const getResource = (resourceType: GlobalResourceType): number => { return resources[resourceType] || 0; }; diff --git a/src/components/Factory/simulation/helpers/advanceProduction.ts b/src/components/Factory/simulation/helpers/advanceProduction.ts index 8f2587579..72ef5684c 100644 --- a/src/components/Factory/simulation/helpers/advanceProduction.ts +++ b/src/components/Factory/simulation/helpers/advanceProduction.ts @@ -1,30 +1,24 @@ import type { Node } from "@xyflow/react"; -import { RESOURCES } from "../../data/resources"; -import { getBuildingData } from "../../types/buildings"; +import { type GlobalResources, isGlobalResource } from "../../data/resources"; +import { getBuildingInstance } from "../../types/buildings"; +import type { ResourceType } from "../../types/resources"; import type { BuildingStatistics } from "../../types/statistics"; export const advanceProduction = ( node: Node, + earnedGlobalResources: GlobalResources, buildingStats: Map, ) => { - const building = getBuildingData(node); + const building = getBuildingInstance(node); if (!building) return; const method = building.productionMethod; - - // Skip global output buildings - check dynamically if any output is global if (!method) return; - const hasGlobalOutputs = method.outputs?.some( - (output) => RESOURCES[output.resource]?.global, - ); - - if (hasGlobalOutputs) return; // Handled by processGlobalOutputBuilding - // Initialize statistics for this building if needed if (!buildingStats.has(node.id)) { - buildingStats.set(node.id, { stockpileChanges: [] }); + buildingStats.set(node.id, { stockpileChanges: [], produced: {} }); } const stats = buildingStats.get(node.id)!; @@ -34,10 +28,14 @@ export const advanceProduction = ( status: "idle" as const, }; - let stockpile = building.stockpile; + let stockpile = building.stockpile || []; // Helper to track stockpile changes - const trackChange = (resource: string, added: number, removed: number) => { + const trackChange = ( + resource: ResourceType, + added: number, + removed: number, + ) => { const existing = stats.stockpileChanges.find( (c) => c.resource === resource, ); @@ -47,7 +45,7 @@ export const advanceProduction = ( existing.net = existing.added - existing.removed; } else { stats.stockpileChanges.push({ - resource: resource as any, + resource, added, removed, net: added - removed, @@ -57,6 +55,8 @@ export const advanceProduction = ( // Helper: Check if building has enough inputs const hasEnoughInputs = (): boolean => { + if (method.inputs.length === 0) return true; // No inputs needed + return method.inputs.every((input) => { const stock = stockpile?.find( (s) => s.resource === input.resource || s.resource === "any", @@ -65,18 +65,25 @@ export const advanceProduction = ( }); }; - // Helper: Check if there's room in output stockpile + // Helper: Check if there's room in output stockpile (only for non-global outputs) const hasOutputSpace = (): boolean => { if (!method.outputs || method.outputs.length === 0) return true; return method.outputs.every((output) => { + // Global outputs don't need stockpile space + if (isGlobalResource(output.resource)) return true; + const stock = stockpile?.find((s) => s.resource === output.resource); - return stock && stock.amount + output.amount <= stock.maxAmount; + // If no stockpile exists for this output, we can't produce it + if (!stock) return false; + return stock.amount + output.amount <= stock.maxAmount; }); }; // Helper: Consume input resources from stockpile const consumeInputs = () => { + if (method.inputs.length === 0) return; // No inputs to consume + stockpile = stockpile?.map((stock) => { const input = method.inputs.find( (i) => @@ -91,19 +98,37 @@ export const advanceProduction = ( }); }; - // Helper: Add output resources to stockpile + // Helper: Add output resources to stockpile or global outputs const addOutputs = () => { - stockpile = stockpile?.map((stock) => { - const output = method.outputs?.find((o) => o.resource === stock.resource); - if (output) { - trackChange(stock.resource, output.amount, 0); - return { - ...stock, - amount: Math.min(stock.maxAmount, stock.amount + output.amount), - }; + const produced: Partial = {}; + + method.outputs?.forEach((output) => { + const { resource } = output; + + if (isGlobalResource(resource)) { + // Add to global outputs + earnedGlobalResources[resource] = + (earnedGlobalResources[resource] || 0) + output.amount; + produced[resource] = output.amount; + } else { + // Add to stockpile + stockpile = stockpile?.map((stock) => { + if (stock.resource === output.resource) { + trackChange(stock.resource, output.amount, 0); + return { + ...stock, + amount: Math.min(stock.maxAmount, stock.amount + output.amount), + }; + } + return stock; + }); } - return stock; }); + + // Track global resource production in stats + if (Object.keys(produced).length > 0) { + stats.produced = produced; + } }; // Step 1: If complete, reset & transition to idle @@ -165,7 +190,7 @@ export const advanceProduction = ( } // Update node with final state - node.data = { + node.data.buildingInstance = { ...building, stockpile, productionState, diff --git a/src/components/Factory/simulation/helpers/processGlobalOutputBuilding.ts b/src/components/Factory/simulation/helpers/processGlobalOutputBuilding.ts deleted file mode 100644 index e16591756..000000000 --- a/src/components/Factory/simulation/helpers/processGlobalOutputBuilding.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { Node } from "@xyflow/react"; - -import { RESOURCE_VALUES, RESOURCES } from "../../data/resources"; -import { getBuildingData } from "../../types/buildings"; -import type { BuildingStatistics } from "../../types/statistics"; - -export const processGlobalOutputBuilding = ( - node: Node, - globalOutputs: Record, - buildingStats: Map, -) => { - const building = getBuildingData(node); - - if (!building) return; - - const method = building.productionMethod; - if (!method) return; - - // Check if this building produces any global outputs - const hasGlobalOutputs = method.outputs.some( - (output) => RESOURCES[output.resource]?.global, - ); - - if (!hasGlobalOutputs) return; - - // Initialize statistics for this building - if (!buildingStats.has(node.id)) { - buildingStats.set(node.id, { stockpileChanges: [], produced: {} }); - } - const stats = buildingStats.get(node.id)!; - - // Special handling for marketplace - calculate value of resources - if (building.id === "marketplace") { - const anyStock = building.stockpile?.find((s) => s.resource === "any"); - - if (!anyStock?.breakdown || anyStock.breakdown.size === 0) { - node.data = { - ...building, - productionState: { progress: 0, status: "idle" }, - }; - return; - } - - let totalMoney = 0; - - // Calculate value of each resource type and track changes - anyStock.breakdown.forEach((amount, resourceType) => { - const resourceValue = RESOURCE_VALUES[resourceType] || 1; - totalMoney += amount * resourceValue; - - // Track that these resources were consumed - const change = stats.stockpileChanges.find( - (c) => c.resource === resourceType, - ); - if (change) { - change.removed += amount; - change.net = change.added - change.removed; - } else { - stats.stockpileChanges.push({ - resource: resourceType, - removed: amount, - added: 0, - net: -amount, - }); - } - }); - - // Add to global outputs dynamically - globalOutputs.money = (globalOutputs.money || 0) + totalMoney; - stats.produced = { money: totalMoney }; - - // Clear marketplace stockpile - node.data = { - ...building, - stockpile: building.stockpile?.map((s) => - s.resource === "any" ? { ...s, amount: 0, breakdown: new Map() } : s, - ), - productionState: { progress: 1, status: "complete" }, - }; - return; - } - - // Generic handling for all other global output buildings (library, etc.) - let cycles = Infinity; - method.inputs.forEach((input) => { - const stock = building.stockpile?.find( - (s) => s.resource === input.resource, - ); - if (stock) { - const possibleCycles = Math.floor(stock.amount / input.amount); - cycles = Math.min(cycles, possibleCycles); - } else { - cycles = 0; - } - }); - - if (cycles > 0 && cycles !== Infinity) { - // Consume all possible inputs and track changes - const updatedStockpile = building.stockpile?.map((stock) => { - const input = method.inputs.find((i) => i.resource === stock.resource); - if (input) { - const consumed = input.amount * cycles; - - // Track stockpile changes - const change = stats.stockpileChanges.find( - (c) => c.resource === stock.resource, - ); - if (change) { - change.removed += consumed; - change.net = change.added - change.removed; - } else { - stats.stockpileChanges.push({ - resource: stock.resource, - removed: consumed, - added: 0, - net: -consumed, - }); - } - - return { ...stock, amount: stock.amount - consumed }; - } - return stock; - }); - - // Add all outputs (filter for global ones) - const produced: Record = {}; - - method.outputs.forEach((output) => { - const isGlobal = RESOURCES[output.resource]?.global; - - if (isGlobal) { - const amount = output.amount * cycles; - globalOutputs[output.resource] = - (globalOutputs[output.resource] || 0) + amount; - produced[output.resource] = amount; - } - }); - - stats.produced = produced; - - node.data = { - ...building, - stockpile: updatedStockpile, - productionState: { progress: 0, status: "idle" }, - }; - } -}; diff --git a/src/components/Factory/simulation/helpers/processSpecialBuilding.ts b/src/components/Factory/simulation/helpers/processSpecialBuilding.ts new file mode 100644 index 000000000..14cb79738 --- /dev/null +++ b/src/components/Factory/simulation/helpers/processSpecialBuilding.ts @@ -0,0 +1,117 @@ +import type { Node } from "@xyflow/react"; + +import { type GlobalResources, RESOURCE_VALUES } from "../../data/resources"; +import { getBuildingInstance } from "../../types/buildings"; +import type { BuildingStatistics } from "../../types/statistics"; + +/** + * Handles special building logic that doesn't follow standard production cycles + * Currently handles: Marketplace (sells any resources for money) + */ +export const processSpecialBuilding = ( + node: Node, + earnedGlobalResources: GlobalResources, + buildingStats: Map, +) => { + const building = getBuildingInstance(node); + if (!building) return; + + // Initialize statistics for this building + if (!buildingStats.has(node.id)) { + buildingStats.set(node.id, { stockpileChanges: [], produced: {} }); + } + const stats = buildingStats.get(node.id)!; + + // Marketplace: Sells any resources for money based on their value + if (building.type === "marketplace") { + const anyStock = building.stockpile?.find((s) => s.resource === "any"); + + // If no stockpile or empty, set to idle + if ( + !anyStock || + anyStock.amount === 0 || + !anyStock.breakdown || + anyStock.breakdown.size === 0 + ) { + node.data = { + ...node.data, + buildingInstance: { + ...building, + productionState: { progress: 0, status: "idle" }, + }, + }; + return; + } + + let totalMoney = 0; + + // Sell each resource type for its value + anyStock.breakdown.forEach((amount, resourceType) => { + if (amount > 0) { + // Get the base value of the resource + const resourceValue = RESOURCE_VALUES[resourceType] || 1; + + // The marketplace production method has a money multiplier on the output + // Get the money multiplier from the production method + const moneyOutput = building.productionMethod.outputs?.find( + (o) => o.resource === "money", + ); + const moneyMultiplier = moneyOutput?.amount || 1; + + // Calculate total money: resource_value * amount * money_multiplier + const moneyForThisResource = amount * resourceValue * moneyMultiplier; + totalMoney += moneyForThisResource; + + // Track that these resources were consumed (sold) + const change = stats.stockpileChanges.find( + (c) => c.resource === resourceType, + ); + if (change) { + change.removed += amount; + change.net = change.added - change.removed; + } else { + stats.stockpileChanges.push({ + resource: resourceType as any, + removed: amount, + added: 0, + net: -amount, + }); + } + } + }); + + // Add money to global outputs + if (totalMoney > 0) { + earnedGlobalResources.money = + (earnedGlobalResources.money || 0) + totalMoney; + + // Track produced money in statistics + if (!stats.produced) { + stats.produced = {}; + } + stats.produced.money = (stats.produced.money || 0) + totalMoney; + } + + // Clear marketplace stockpile completely - both amount AND breakdown + const updatedBuilding = { + ...building, + stockpile: building.stockpile?.map((s) => + s.resource === "any" + ? { + ...s, + amount: 0, + breakdown: new Map(), + } + : s, + ), + productionState: { progress: 1, status: "complete" as const }, + }; + + node.data = { + ...node.data, + buildingInstance: updatedBuilding, + }; + } + + // Add more special buildings here as needed +}; diff --git a/src/components/Factory/simulation/helpers/transferResources.ts b/src/components/Factory/simulation/helpers/transferResources.ts index 04cbb2c11..a9238df82 100644 --- a/src/components/Factory/simulation/helpers/transferResources.ts +++ b/src/components/Factory/simulation/helpers/transferResources.ts @@ -1,6 +1,6 @@ import type { Edge, Node } from "@xyflow/react"; -import { getBuildingData } from "../../types/buildings"; +import { getBuildingInstance } from "../../types/buildings"; import type { BuildingStatistics, EdgeStatistics, @@ -20,8 +20,8 @@ export const transferResources = ( if (!sourceNode || !targetNode) return; - const sourceBuilding = getBuildingData(sourceNode); - const targetBuilding = getBuildingData(targetNode); + const sourceBuilding = getBuildingInstance(sourceNode); + const targetBuilding = getBuildingInstance(targetNode); if (!sourceBuilding || !targetBuilding) return; @@ -104,8 +104,8 @@ export const transferResources = ( }); } - // Update source stockpile - sourceNode.data = { + // Update source stockpile - preserve buildingInstance structure + const updatedSourceBuilding = { ...sourceBuilding, stockpile: sourceBuilding.stockpile?.map((s) => s.resource === resource @@ -114,28 +114,37 @@ export const transferResources = ( ), }; - // Update target stockpile + sourceNode.data = { + ...sourceNode.data, + buildingInstance: updatedSourceBuilding, + }; + + // Update target stockpile - preserve buildingInstance structure if (targetStock.resource === "any") { - // For "any" stockpiles, track the specific resource in breakdown - const breakdown = targetStock.breakdown || new Map(); + const breakdown = new Map(targetStock.breakdown || new Map()); const currentAmount = breakdown.get(resource) || 0; breakdown.set(resource, currentAmount + transferAmount); - targetNode.data = { + const updatedTargetBuilding = { ...targetBuilding, stockpile: targetBuilding.stockpile?.map((s) => s.resource === "any" ? { ...s, amount: s.amount + transferAmount, - breakdown: new Map(breakdown), + breakdown, } : s, ), }; + + targetNode.data = { + ...targetNode.data, + buildingInstance: updatedTargetBuilding, + }; } else { // Regular stockpile transfer - targetNode.data = { + const updatedTargetBuilding = { ...targetBuilding, stockpile: targetBuilding.stockpile?.map((s) => s.resource === resource @@ -143,6 +152,11 @@ export const transferResources = ( : s, ), }; + + targetNode.data = { + ...targetNode.data, + buildingInstance: updatedTargetBuilding, + }; } } }); diff --git a/src/components/Factory/simulation/processDay.ts b/src/components/Factory/simulation/processDay.ts index 64ea36507..e1affb5fc 100644 --- a/src/components/Factory/simulation/processDay.ts +++ b/src/components/Factory/simulation/processDay.ts @@ -1,157 +1,131 @@ import type { Edge, Node } from "@xyflow/react"; -import { RESOURCES } from "../data/resources"; -import { getBuildingData } from "../types/buildings"; +import { GLOBAL_RESOURCE_KEYS, type GlobalResources } from "../data/resources"; +import { getBuildingInstance } from "../types/buildings"; import type { BuildingStatistics, DayStatistics, EdgeStatistics, + GlobalStatistics, } from "../types/statistics"; import { advanceProduction } from "./helpers/advanceProduction"; -import { processGlobalOutputBuilding } from "./helpers/processGlobalOutputBuilding"; +import { processSpecialBuilding } from "./helpers/processSpecialBuilding"; import { transferResources } from "./helpers/transferResources"; -interface ProcessDayResult { - updatedNodes: Node[]; - globalOutputs: Record; - statistics: DayStatistics; -} +const SPECIAL_BUILDINGS = ["marketplace"]; -// Removed currentMoney and currentKnowledge parameters - now dynamic export const processDay = ( nodes: Node[], edges: Edge[], - currentDay: number, - currentResources: Record, -): ProcessDayResult => { - const updatedNodes = nodes.map((node) => ({ - ...node, - data: { ...node.data }, - })); - - // Initialize global outputs dynamically - const globalOutputs: Record = {}; - - // Initialize statistics tracking + day: number, + currentResources: GlobalResources, +): { + updatedNodes: Node[]; + statistics: DayStatistics; +} => { + const updatedNodes = structuredClone(nodes); + const earnedGlobalResources = Object.fromEntries( + GLOBAL_RESOURCE_KEYS.map((key) => [key, 0]), + ) as GlobalResources; + + // Initialize statistics const buildingStats = new Map(); const edgeStats = new Map(); - // Build adjacency maps - const upstreamMap = new Map(); - const downstreamMap = new Map(); + // Build adjacency lists (both forward and reverse) + const downstream = new Map(); // node -> nodes it outputs to + const upstream = new Map(); // node -> nodes that output to it + const outDegree = new Map(); // how many downstream nodes - edges.forEach((edge) => { - // Upstream map: target -> sources - if (!upstreamMap.has(edge.target)) { - upstreamMap.set(edge.target, []); - } - upstreamMap.get(edge.target)!.push(edge.source); - - // Downstream map: source -> targets - if (!downstreamMap.has(edge.source)) { - downstreamMap.set(edge.source, []); - } - downstreamMap.get(edge.source)!.push(edge.target); + updatedNodes.forEach((node) => { + downstream.set(node.id, []); + upstream.set(node.id, []); + outDegree.set(node.id, 0); }); - // Find sink nodes (buildings that produce global outputs) - const sinkNodes = updatedNodes.filter((node) => { - const building = getBuildingData(node); - return building?.productionMethod?.outputs.some( - (output) => RESOURCES[output.resource]?.global, - ); + edges.forEach((edge) => { + downstream.get(edge.source)?.push(edge.target); + upstream.get(edge.target)?.push(edge.source); + outDegree.set(edge.source, (outDegree.get(edge.source) || 0) + 1); }); - // Track visited nodes for BFS - const visited = new Set(); - - // STEP 1: Process global output buildings (sinks) - sinkNodes.forEach((node) => { - processGlobalOutputBuilding(node, globalOutputs, buildingStats); - visited.add(node.id); + // ✅ STEP 1: Find all sink nodes (nodes with no downstream connections) + const queue: string[] = []; + updatedNodes.forEach((node) => { + if (outDegree.get(node.id) === 0) { + queue.push(node.id); + } }); - // Build processing order via BFS from sinks - const processingOrder: string[] = []; - const queue: string[] = [...sinkNodes.map((n) => n.id)]; + const processed = new Set(); + // ✅ STEP 2: Process nodes from sinks upstream while (queue.length > 0) { - const nodeId = queue.shift()!; - - const upstreamNodes = upstreamMap.get(nodeId) || []; - upstreamNodes.forEach((upstreamId) => { - if (!visited.has(upstreamId)) { - visited.add(upstreamId); - processingOrder.push(upstreamId); - queue.push(upstreamId); - } - }); - } + const currentNodeId = queue.shift()!; + if (processed.has(currentNodeId)) continue; - // STEP 2: Transfer resources (in reverse order - from upstream to downstream) - processingOrder.forEach((nodeId) => { - const downstreamNodes = downstreamMap.get(nodeId) || []; - downstreamNodes.forEach((downstreamId) => { + const currentNode = updatedNodes.find((n) => n.id === currentNodeId); + if (!currentNode) continue; + + const building = getBuildingInstance(currentNode); + if (!building) continue; + + // ✅ STEP 2.1: If there is downstream, transfer resources downstream + const downstreamNodes = downstream.get(currentNodeId) || []; + downstreamNodes.forEach((neighborId) => { transferResources( - nodeId, - downstreamId, + currentNodeId, + neighborId, updatedNodes, edges, buildingStats, edgeStats, ); }); - }); - // STEP 3: Advance production for all non-sink nodes - processingOrder.forEach((nodeId) => { - const node = updatedNodes.find((n) => n.id === nodeId); - if (node) { - advanceProduction(node, buildingStats); + // ✅ STEP 2.2: If special node, process special, otherwise advance production + if (SPECIAL_BUILDINGS.includes(building.type)) { + processSpecialBuilding(currentNode, earnedGlobalResources, buildingStats); + } else { + advanceProduction(currentNode, earnedGlobalResources, buildingStats); } - }); - // Handle disconnected nodes (not connected to any sink) - updatedNodes.forEach((node) => { - if (!visited.has(node.id)) { - // Transfer to any downstream connections - const downstreamNodes = downstreamMap.get(node.id) || []; - downstreamNodes.forEach((downstreamId) => { - transferResources( - node.id, - downstreamId, - updatedNodes, - edges, - buildingStats, - edgeStats, - ); - }); - - // Advance production - advanceProduction(node, buildingStats); - visited.add(node.id); - } - }); + // Mark as processed + processed.add(currentNodeId); + + // ✅ STEP 2.3: Move upstream - add upstream nodes if all their downstream nodes are processed + const upstreamNodes = upstream.get(currentNodeId) || []; + upstreamNodes.forEach((upstreamNodeId) => { + const upstreamDownstreamNodes = downstream.get(upstreamNodeId) || []; + const allDownstreamProcessed = upstreamDownstreamNodes.every((id) => + processed.has(id), + ); + + if (allDownstreamProcessed && !processed.has(upstreamNodeId)) { + queue.push(upstreamNodeId); + } + }); + } - // Build final statistics object with dynamic resources - const updatedResources: Record = { ...currentResources }; - const earned: Record = {}; + const finalResources = { ...currentResources }; - // Update all global resources that were produced - Object.entries(globalOutputs).forEach(([resource, amount]) => { - updatedResources[resource] = (updatedResources[resource] || 0) + amount; - earned[resource] = amount; + GLOBAL_RESOURCE_KEYS.forEach((key) => { + finalResources[key] += earnedGlobalResources[key]; }); - const statistics: DayStatistics = { - global: { - day: currentDay, - resources: updatedResources, - earned, - }, - buildings: buildingStats, - edges: edgeStats, + // Build global statistics + const globalStats: GlobalStatistics = { + day, + earned: earnedGlobalResources, + resources: finalResources, }; - return { updatedNodes, globalOutputs, statistics }; + return { + updatedNodes, + statistics: { + global: globalStats, + buildings: buildingStats, + edges: edgeStats, + }, + }; }; diff --git a/src/components/Factory/types/buildings.ts b/src/components/Factory/types/buildings.ts index 7feebec8e..abe5ec046 100644 --- a/src/components/Factory/types/buildings.ts +++ b/src/components/Factory/types/buildings.ts @@ -1,5 +1,7 @@ import type { Node, Position } from "@xyflow/react"; +import { BUILDINGS } from "../data/buildings"; +import type { ProductionMethod, ProductionState } from "./production"; import type { ResourceType } from "./resources"; export type BuildingInput = { @@ -12,19 +14,6 @@ export type BuildingOutput = { position?: Position; }; -export type ProductionMethod = { - name: string; - inputs: Array<{ - resource: ResourceType; - amount: number; - }>; - outputs: Array<{ - resource: ResourceType; - amount: number; - }>; - days: number; -}; - export type Stockpile = { resource: ResourceType; amount: number; @@ -32,42 +21,108 @@ export type Stockpile = { breakdown?: Map; }; -export type ProductionStatus = "idle" | "active" | "paused" | "complete"; - -export type ProductionState = { - progress: number; - status: ProductionStatus; -}; - -export interface Building { - id: string; +export interface BuildingClass { name: string; icon: string; description: string; - cost?: number; + cost: number; color: string; - inputs?: BuildingInput[]; - outputs?: BuildingOutput[]; - productionMethod?: ProductionMethod; - stockpile?: Stockpile[]; - productionState?: ProductionState; + productionMethods: ProductionMethod[]; +} + +export interface BuildingInstance extends Omit< + BuildingClass, + "productionMethods" +> { + id: string; + type: BuildingType; + inputs: BuildingInput[]; + outputs: BuildingOutput[]; + stockpile: Stockpile[]; + productionMethod: ProductionMethod; + productionState: ProductionState; +} + +export type BuildingNodeData = Record & { + buildingInstance: BuildingInstance; +}; + +export function getBuildingType(buildingType: string): BuildingClass { + const building = BUILDINGS[buildingType]; + + if (!building) { + throw new Error(`Building type ${buildingType} not found`); + } + + return building; +} + +export function isBuildingInstance(node: Node): node is Node; +export function isBuildingInstance(data: any): data is BuildingInstance; + +// Implementation +export function isBuildingInstance(nodeOrData: any): boolean { + if ( + nodeOrData !== null && + typeof nodeOrData === "object" && + "data" in nodeOrData + ) { + const data = nodeOrData.data; + return isBuildingInstanceData(data); + } + + return isBuildingInstanceData(nodeOrData); } -export function isBuildingData(data: any): data is Building { +function isBuildingInstanceData(data: any): data is BuildingInstance { return ( - typeof data === "object" && data !== null && + typeof data === "object" && typeof data.id === "string" && + typeof data.type === "string" && typeof data.name === "string" && typeof data.icon === "string" && typeof data.description === "string" && - typeof data.color === "string" + typeof data.cost === "number" && + typeof data.color === "string" && + Array.isArray(data.inputs) && + Array.isArray(data.outputs) && + Array.isArray(data.stockpile) && + typeof data.productionMethod === "object" && + typeof data.productionState === "object" ); } -export function getBuildingData(node: Node): Building | null { - if (isBuildingData(node.data)) { - return node.data; +export function getBuildingInstance( + node: Node | undefined, +): BuildingInstance | null; +export function getBuildingInstance( + data: Record, +): BuildingInstance | null; + +// Implementation +export function getBuildingInstance( + nodeOrData: Node | undefined | Record, +): BuildingInstance | null { + if (!nodeOrData) return null; + + // Node + if ("data" in nodeOrData && typeof nodeOrData.data === "object") { + const node = nodeOrData as Node; + if (node.data && isBuildingInstance(node.data.buildingInstance)) { + return node.data.buildingInstance; + } + return null; } + + // Data + const data = nodeOrData as Record; + if (isBuildingInstance(data.buildingInstance)) { + return data.buildingInstance; + } + return null; } + +export type BuildingType = keyof typeof BUILDINGS; +export const BUILDING_TYPES = Object.keys(BUILDINGS) as BuildingType[]; diff --git a/src/components/Factory/types/production.ts b/src/components/Factory/types/production.ts new file mode 100644 index 000000000..228e577db --- /dev/null +++ b/src/components/Factory/types/production.ts @@ -0,0 +1,23 @@ +import type { ResourceType } from "./resources"; + +export type ProductionMethod = { + name: string; + inputs: Array<{ + resource: ResourceType; + amount: number; + nodes?: number; + }>; + outputs: Array<{ + resource: ResourceType; + amount: number; + nodes?: number; + }>; + days: number; +}; + +type ProductionStatus = "idle" | "active" | "paused" | "complete"; + +export type ProductionState = { + progress: number; + status: ProductionStatus; +}; diff --git a/src/components/Factory/types/statistics.ts b/src/components/Factory/types/statistics.ts index 4c7bd7dad..c77b4ee8a 100644 --- a/src/components/Factory/types/statistics.ts +++ b/src/components/Factory/types/statistics.ts @@ -1,9 +1,10 @@ +import type { GlobalResources } from "../data/resources"; import type { ResourceType } from "./resources"; export interface GlobalStatistics { day: number; - resources: Record; - earned: Record; + resources: GlobalResources; + earned: GlobalResources; } export interface StockpileChange { @@ -15,7 +16,7 @@ export interface StockpileChange { export interface BuildingStatistics { stockpileChanges: StockpileChange[]; - produced?: Record; + produced?: Partial>; } export interface EdgeStatistics {