From 14f29f763dec5e4c71ffc7ef1415ef64951b4418 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Mon, 16 Feb 2026 18:15:55 -0800 Subject: [PATCH] hackdays: Add Splitters and Mergers --- .../Factory/Canvas/Edges/ResourceEdge.tsx | 22 +- .../Factory/Canvas/Nodes/Building.tsx | 33 +- .../Factory/Canvas/callbacks/onConnect.ts | 2 + .../Context/Building/BuildingContext.tsx | 5 +- .../Context/Building/Production/Inputs.tsx | 9 +- .../Context/Building/Production/Outputs.tsx | 8 + .../Production/ProductionMethodSection.tsx | 3 +- .../Context/Building/StockpileSection.tsx | 8 - .../Factory/Context/ResourceContext.tsx | 21 +- .../Factory/Sidebar/BuildingFolder.tsx | 40 +- .../Factory/Sidebar/BuildingItem.tsx | 8 +- src/components/Factory/Sidebar/Buildings.tsx | 29 +- .../Factory/components/BuildingIcon.tsx | 19 + .../components/TransportationFeedback.tsx | 14 +- src/components/Factory/data/buildings.ts | 131 +++++- src/components/Factory/data/resources.ts | 14 +- .../buildings/createBuildingInstance.ts | 4 +- .../configureBuildingInstanceForMethod.ts | 312 +++++++------ .../objects/resources/createResourceEdge.ts | 39 +- .../Factory/providers/StatisticsProvider.tsx | 35 +- .../simulation/helpers/transferResources.ts | 435 +++++++++++------- .../transferResourcesEvenlyDownstream.ts | 74 +-- src/components/Factory/types/buildings.ts | 68 ++- src/components/Factory/types/resources.ts | 1 + 24 files changed, 832 insertions(+), 502 deletions(-) create mode 100644 src/components/Factory/components/BuildingIcon.tsx diff --git a/src/components/Factory/Canvas/Edges/ResourceEdge.tsx b/src/components/Factory/Canvas/Edges/ResourceEdge.tsx index 803ff86e0..521952173 100644 --- a/src/components/Factory/Canvas/Edges/ResourceEdge.tsx +++ b/src/components/Factory/Canvas/Edges/ResourceEdge.tsx @@ -37,12 +37,14 @@ const ResourceEdge = ({ clearContent, setOpen: setContextPanelOpen, } = useContextPanel(); - const { getLatestBuildingStats } = useStatistics(); - const sourceStats = getLatestBuildingStats(source); - const resourcesTransferred = sourceStats?.stockpileChanges.filter( - (c) => c.removed > 0, - ); + const { getLatestEdgeStats } = useStatistics(); + + const edgeResource = isResourceData(data) ? data.type : undefined; + const edgeTransfers = + edgeResource === "any" + ? getLatestEdgeStats(id, true) // todo: saved stats appear to be incorrect after merging and splitting again (especially if it's a sushi belt) - note the correct amounts are transferred downstream to the building. it's just the value saved int he stats & shown onscreen that is wrong. + : getLatestEdgeStats(id); const [edgePath, labelX, labelY] = getBezierPath({ sourceX, @@ -68,6 +70,7 @@ const ResourceEdge = ({ resource={data} sourceNodeId={source} targetNodeId={target} + edgeId={id} />, ); setContextPanelOpen(true); @@ -83,6 +86,7 @@ const ResourceEdge = ({ data, source, target, + id, setContent, clearContent, setContextPanelOpen, @@ -111,19 +115,17 @@ const ResourceEdge = ({ }} interactionWidth={20} /> - {resourcesTransferred && ( + {edgeTransfers && edgeTransfers.length > 0 && (
- +
)} diff --git a/src/components/Factory/Canvas/Nodes/Building.tsx b/src/components/Factory/Canvas/Nodes/Building.tsx index fcfd736bc..1ea09f5be 100644 --- a/src/components/Factory/Canvas/Nodes/Building.tsx +++ b/src/components/Factory/Canvas/Nodes/Building.tsx @@ -5,9 +5,11 @@ import { } from "@xyflow/react"; import { useEffect, useRef } from "react"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; import { cn } from "@/lib/utils"; import { useContextPanel } from "@/providers/ContextPanelProvider"; +import BuildingIcon from "../../components/BuildingIcon"; import { ProductionFeedback } from "../../components/ProductionFeedback"; import BuildingContext from "../../Context/Building/BuildingContext"; import { isGlobalResource } from "../../data/resources"; @@ -80,6 +82,7 @@ const Building = ({ id, data, selected }: NodeProps) => { } const { + type, icon, name, description, @@ -96,6 +99,8 @@ const Building = ({ id, data, selected }: NodeProps) => { const inputIndexAtPosition: Record = {}; const outputIndexAtPosition: Record = {}; + const isSplitterOrMerger = type === "splitter" || type === "merger"; + return (
{ })}
-
- {icon} {name} -
-
- {description} -
+ {isSplitterOrMerger ? ( +
+ +
+ ) : ( + + + +

+ {name} +

+
+
+ {description} +
+
+ )}
{outputs.map((output, globalIndex) => { diff --git a/src/components/Factory/Canvas/callbacks/onConnect.ts b/src/components/Factory/Canvas/callbacks/onConnect.ts index ec03e4061..f4573448d 100644 --- a/src/components/Factory/Canvas/callbacks/onConnect.ts +++ b/src/components/Factory/Canvas/callbacks/onConnect.ts @@ -42,6 +42,8 @@ export const createOnConnect = ( connection.target, edgeResource, reactFlowInstance, + connection.sourceHandle, + connection.targetHandle, ); if (!newEdge) return; diff --git a/src/components/Factory/Context/Building/BuildingContext.tsx b/src/components/Factory/Context/Building/BuildingContext.tsx index 808bc1fb1..02a582b68 100644 --- a/src/components/Factory/Context/Building/BuildingContext.tsx +++ b/src/components/Factory/Context/Building/BuildingContext.tsx @@ -3,8 +3,9 @@ import { useReactFlow, useUpdateNodeInternals } from "@xyflow/react"; import { BlockStack } from "@/components/ui/layout"; import { Separator } from "@/components/ui/separator"; +import { getBuildingDefinition } from "../../data/buildings"; import { configureBuildingInstanceForMethod } from "../../objects/production/configureBuildingInstanceForMethod"; -import { type BuildingInstance, getBuildingType } from "../../types/buildings"; +import { type BuildingInstance } from "../../types/buildings"; import type { ProductionMethod } from "../../types/production"; import { ContextHeader } from "../shared/ContextHeader"; import { BuildingDescription } from "./BuildingDescription"; @@ -21,7 +22,7 @@ const BuildingContext = ({ building, nodeId }: BuildingContextProps) => { const { updateNodeData, getEdges, setEdges } = useReactFlow(); const updateNodeInternals = useUpdateNodeInternals(); - const buildingClass = getBuildingType(building.type); + const buildingClass = getBuildingDefinition(building.type); const availableMethods = buildingClass.productionMethods; const { diff --git a/src/components/Factory/Context/Building/Production/Inputs.tsx b/src/components/Factory/Context/Building/Production/Inputs.tsx index a24eb2a8d..39d95e93f 100644 --- a/src/components/Factory/Context/Building/Production/Inputs.tsx +++ b/src/components/Factory/Context/Building/Production/Inputs.tsx @@ -1,6 +1,7 @@ import { SPECIAL_BUILDINGS } from "@/components/Factory/data/buildings"; import { getResourceTypeFoodValue, + isGlobalResource, RESOURCES, } from "@/components/Factory/data/resources"; import type { BuildingType } from "@/components/Factory/types/buildings"; @@ -23,12 +24,8 @@ export const ProductionInputs = ({ // Determine which global resource this special building outputs const globalOutput = isSpecialBuilding - ? productionMethod.outputs.find( - (o) => - o.resource === "money" || - o.resource === "food" || - o.resource === "knowledge", - )?.resource + ? productionMethod.outputs.find((o) => isGlobalResource(o.resource)) + ?.resource : null; return ( diff --git a/src/components/Factory/Context/Building/Production/Outputs.tsx b/src/components/Factory/Context/Building/Production/Outputs.tsx index 4b32c191c..fa4ad5ad6 100644 --- a/src/components/Factory/Context/Building/Production/Outputs.tsx +++ b/src/components/Factory/Context/Building/Production/Outputs.tsx @@ -23,6 +23,14 @@ export const ProductionOutputs = ({ Outputs: {productionMethod.outputs.map((output, idx) => { + if (output.resource === "any") { + return ( + + • any + + ); + } + if (isGlobalResource(output.resource)) { return ( diff --git a/src/components/Factory/Context/Building/Production/ProductionMethodSection.tsx b/src/components/Factory/Context/Building/Production/ProductionMethodSection.tsx index 865dadb11..dbc3b52e8 100644 --- a/src/components/Factory/Context/Building/Production/ProductionMethodSection.tsx +++ b/src/components/Factory/Context/Building/Production/ProductionMethodSection.tsx @@ -1,3 +1,4 @@ +import type { BuildingType } from "@/components/Factory/types/buildings"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; @@ -13,7 +14,7 @@ import { ProductionMethodSwitcher } from "./ProductionMethodSwitcher"; import { ProductionProgress } from "./Progress"; interface ProductionMethodSectionProps { - buildingType: string; + buildingType: BuildingType; productionMethod?: ProductionMethod; productionState?: ProductionState; availableMethods?: ProductionMethod[]; diff --git a/src/components/Factory/Context/Building/StockpileSection.tsx b/src/components/Factory/Context/Building/StockpileSection.tsx index c278bb92a..b2bcd6a89 100644 --- a/src/components/Factory/Context/Building/StockpileSection.tsx +++ b/src/components/Factory/Context/Building/StockpileSection.tsx @@ -76,14 +76,6 @@ export const StockpileSection = ({ {resource}: {amount} -
-
-
); }, diff --git a/src/components/Factory/Context/ResourceContext.tsx b/src/components/Factory/Context/ResourceContext.tsx index a2b99e36b..111b6541e 100644 --- a/src/components/Factory/Context/ResourceContext.tsx +++ b/src/components/Factory/Context/ResourceContext.tsx @@ -10,22 +10,22 @@ interface ResourceContextProps { resource: Resource; sourceNodeId?: string; targetNodeId?: string; + edgeId?: string; } const ResourceContext = ({ resource, sourceNodeId, targetNodeId, + edgeId, }: ResourceContextProps) => { const { name, description, color, icon, value, foodValue } = resource; - const { getLatestBuildingStats } = useStatistics(); - const sourceStats = sourceNodeId - ? getLatestBuildingStats(sourceNodeId) - : undefined; - const resourcesTransferred = sourceStats?.stockpileChanges.filter( - (c) => c.removed > 0, - ); + const { getLatestEdgeStats } = useStatistics(); + + const edgeTransfers = edgeId + ? getLatestEdgeStats(edgeId, resource.type === "any") + : []; return ( Transported Yesterday: - {resourcesTransferred && resourcesTransferred.length > 0 ? ( - resourcesTransferred.map((c, index) => ( + {edgeTransfers.length > 0 ? ( + edgeTransfers.map((transfer, index) => ( - {RESOURCES[c.resource].icon} {c.removed} {name} + {RESOURCES[transfer.resource].icon} {transfer.transferred}{" "} + {RESOURCES[transfer.resource].name} )) ) : ( diff --git a/src/components/Factory/Sidebar/BuildingFolder.tsx b/src/components/Factory/Sidebar/BuildingFolder.tsx index 602e76bfa..747db55e6 100644 --- a/src/components/Factory/Sidebar/BuildingFolder.tsx +++ b/src/components/Factory/Sidebar/BuildingFolder.tsx @@ -5,29 +5,17 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import { Icon, type IconName } from "@/components/ui/icon"; +import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { cn } from "@/lib/utils"; -import type { BuildingCategory, BuildingType } from "../types/buildings"; +import { + BUILDING_CATEGORIES, + type BuildingCategory, + type BuildingType, +} from "../types/buildings"; import BuildingItem from "./BuildingItem"; -const CATEGORY_LABELS: Record = { - special: "Special", - production: "Production", - refining: "Refining", - utility: "Utility", - storage: "Storage", -}; - -const CATEGORY_ICONS: Record = { - special: "Star", - production: "Hammer", - refining: "Factory", - utility: "Wrench", - storage: "Package", -}; - type BuildingFolderProps = { category: BuildingCategory; buildings: BuildingType[]; @@ -36,8 +24,14 @@ type BuildingFolderProps = { const BuildingFolder = ({ category, buildings }: BuildingFolderProps) => { const [isOpen, setIsOpen] = useState(false); + const buildingCategory = BUILDING_CATEGORIES.find( + (cat) => cat.type === category, + ); + + if (!buildingCategory) return null; + return ( - + { > - {CATEGORY_LABELS[category]} + {buildingCategory.label} ({buildings.length}) @@ -65,8 +59,8 @@ const BuildingFolder = ({ category, buildings }: BuildingFolderProps) => { /> - - + + {buildings.map((buildingType) => ( ))} diff --git a/src/components/Factory/Sidebar/BuildingItem.tsx b/src/components/Factory/Sidebar/BuildingItem.tsx index 902b6e4a5..86f051fd5 100644 --- a/src/components/Factory/Sidebar/BuildingItem.tsx +++ b/src/components/Factory/Sidebar/BuildingItem.tsx @@ -3,15 +3,17 @@ import type { DragEvent } from "react"; import { InlineStack } from "@/components/ui/layout"; import { cn } from "@/lib/utils"; +import BuildingIcon from "../components/BuildingIcon"; +import { getBuildingDefinition } from "../data/buildings"; import { RESOURCES } from "../data/resources"; -import { type BuildingType, getBuildingType } from "../types/buildings"; +import type { BuildingType } from "../types/buildings"; interface BuildingItemProps { buildingType: BuildingType; } const BuildingItem = ({ buildingType }: BuildingItemProps) => { - const building = getBuildingType(buildingType); + const building = getBuildingDefinition(buildingType); const onDragStart = (event: DragEvent) => { event.dataTransfer.setData( @@ -40,7 +42,7 @@ const BuildingItem = ({ buildingType }: BuildingItemProps) => { onDragStart={onDragStart} > - {building.icon} +
{ // Group buildings by category const buildingsByCategory = getBuildingsByCategory(); @@ -22,15 +17,15 @@ const Buildings = () => { Buildings - {CATEGORY_ORDER.map((category) => { - const buildings = buildingsByCategory.get(category) || []; + {BUILDING_CATEGORIES.map((category) => { + const buildings = buildingsByCategory.get(category.type) || []; if (buildings.length === 0) return null; return ( ); @@ -45,8 +40,8 @@ export default Buildings; function getBuildingsByCategory() { const grouped = new Map(); - CATEGORY_ORDER.forEach((category) => { - grouped.set(category, []); + BUILDING_CATEGORIES.forEach((category) => { + grouped.set(category.type, []); }); BUILDING_TYPES.forEach((buildingType) => { diff --git a/src/components/Factory/components/BuildingIcon.tsx b/src/components/Factory/components/BuildingIcon.tsx new file mode 100644 index 000000000..3163aaba1 --- /dev/null +++ b/src/components/Factory/components/BuildingIcon.tsx @@ -0,0 +1,19 @@ +import { icons } from "lucide-react"; + +import { Icon, type IconName } from "@/components/ui/icon"; + +interface BuildingIconProps { + icon: string; +} + +const BuildingIcon = ({ icon }: BuildingIconProps) => { + const isLucideIcon = icon in icons; + + if (isLucideIcon) { + return ; + } + + return {icon}; +}; + +export default BuildingIcon; diff --git a/src/components/Factory/components/TransportationFeedback.tsx b/src/components/Factory/components/TransportationFeedback.tsx index 2c6dc778e..110c58250 100644 --- a/src/components/Factory/components/TransportationFeedback.tsx +++ b/src/components/Factory/components/TransportationFeedback.tsx @@ -1,27 +1,27 @@ import { InlineStack } from "@/components/ui/layout"; import { RESOURCES } from "../data/resources"; -import type { StockpileChange } from "../types/statistics"; +import type { EdgeStatistics } from "../types/statistics"; interface TransportationFeedbackProps { - resourcesTransferred?: StockpileChange[]; + transfers: EdgeStatistics[]; } export const TransportationFeedback = ({ - resourcesTransferred, + transfers, }: TransportationFeedbackProps) => { - if (!resourcesTransferred || resourcesTransferred.length === 0) { + if (!transfers || transfers.length === 0) { return null; } return ( - {resourcesTransferred.map((c, index) => ( + {transfers.map((transfer, index) => (

{`${RESOURCES[c.resource].icon} ${c.removed}`}

+ style={{ color: RESOURCES[transfer.resource].color }} + >{`${RESOURCES[transfer.resource].icon} ${transfer.transferred}`}

))}
); diff --git a/src/components/Factory/data/buildings.ts b/src/components/Factory/data/buildings.ts index fb4a0b3b2..3ce1930b2 100644 --- a/src/components/Factory/data/buildings.ts +++ b/src/components/Factory/data/buildings.ts @@ -1,16 +1,18 @@ -import type { BuildingClass } from "../types/buildings"; +import type { BuildingClass, BuildingType } from "../types/buildings"; -export const SPECIAL_BUILDINGS = [ +export const SPECIAL_BUILDINGS: BuildingType[] = [ "firepit", + "splitter", + "merger", "tradingpost", "marketplace", "storagepit", "granary", ]; // Buildings with special processing logic that doesn't fit the standard production model -export const BUILDINGS: Record = { +export const BUILDINGS: Record = { firepit: { - name: "Firepit", + name: "Fire Pit", icon: "🔥", description: "The centre of civilization", cost: 0, @@ -192,10 +194,10 @@ export const BUILDINGS: Record = { name: "Orchard", inputs: [], outputs: [{ resource: "berries", amount: 100, nodes: 4 }], - days: 2, + days: 4, }, { - name: "Irrigation", + name: "Irrigated Wheat", inputs: [{ resource: "water", amount: 60, nodes: 3 }], outputs: [{ resource: "wheat", amount: 200, nodes: 4 }], days: 3, @@ -240,25 +242,28 @@ export const BUILDINGS: Record = { pasture: { name: "Pasture", icon: "🐄", - description: "Raises livestock", + description: "Raises animals", cost: 0, color: "#A52A2A", category: "production", productionMethods: [ { - name: "Grazing", - inputs: [{ resource: "wheat", amount: 25 }], + name: "Livestock", + inputs: [ + { resource: "wheat", amount: 25 }, + { resource: "water", amount: 20 }, + ], outputs: [{ resource: "livestock", amount: 1 }], days: 10, }, { - name: "Feeding", + name: "Horses", inputs: [ - { resource: "wheat", amount: 10 }, - { resource: "water", amount: 20 }, + { resource: "wheat", amount: 50, nodes: 2 }, + { resource: "water", amount: 50, nodes: 2 }, ], - outputs: [{ resource: "livestock", amount: 1 }], - days: 10, + outputs: [{ resource: "horses", amount: 1 }], + days: 15, }, ], }, @@ -303,7 +308,7 @@ export const BUILDINGS: Record = { mill: { name: "Mill", icon: "🏭", - description: "Grinds wheat into flour", + description: "Grind wheat into flour", cost: 0, color: "#DAA520", category: "refining", @@ -335,9 +340,9 @@ export const BUILDINGS: Record = { bakery: { name: "Bakery", icon: "🍞", - description: "Bakes flour into bread", + description: "Bakes bread and other treats!", cost: 0, - color: "#F5DEB3", + color: "#F5CEB3", category: "refining", productionMethods: [ { @@ -358,7 +363,7 @@ export const BUILDINGS: Record = { description: "Too big to fail", cost: 100, color: "#FFD700", - category: "utility", + category: "services", productionMethods: [ { name: "Deposit Coins", @@ -387,7 +392,7 @@ export const BUILDINGS: Record = { fishing: { name: "Fishing Dock", icon: "🎣", - description: "Catches fish from the water", + description: "Catches fish", cost: 0, color: "#1E90FF", category: "production", @@ -407,25 +412,31 @@ export const BUILDINGS: Record = { ], }, hunting: { - name: "Hunting Lodge", + name: "Hunting Grounds", icon: "🏹", - description: "Hunts wild animals for resources", + description: "Hunts wild animals", cost: 0, color: "#8B4513", category: "production", productionMethods: [ { - name: "Hunting", - inputs: [{ resource: "tools", amount: 2 }], + name: "Bows", + inputs: [{ resource: "tools", amount: 1 }], outputs: [{ resource: "meat", amount: 5 }], days: 1, }, + { + name: "Horseback", + inputs: [{ resource: "horses", amount: 1 }], + outputs: [{ resource: "meat", amount: 20, nodes: 2 }], + days: 1, + }, ], }, granary: { name: "Granary", icon: "🫙", - description: "Stores food for future use", + description: "Food storage", cost: 0, color: "#FFD700", category: "storage", @@ -568,4 +579,74 @@ export const BUILDINGS: Record = { }, ], }, -} as const satisfies Record; + splitter: { + name: "Splitter", + icon: "Split", + description: "Splits an input into multiple outputs", + cost: 0, + color: "#808080", + category: "logistics", + productionMethods: [ + { + name: "Ancient", + inputs: [{ resource: "any", amount: 12 }], + outputs: [{ resource: "any", amount: 12, nodes: 3 }], + days: 1, + }, + { + name: "Medieval", + inputs: [{ resource: "any", amount: 48 }], + outputs: [{ resource: "any", amount: 48, nodes: 6 }], + days: 1, + }, + { + name: "Modern", + inputs: [{ resource: "any", amount: 108 }], + outputs: [{ resource: "any", amount: 108, nodes: 9 }], + days: 1, + }, + ], + }, + merger: { + name: "Merger", + icon: "Merge", + description: "Merges multiple inputs into one output", + cost: 0, + color: "#808080", + category: "logistics", + productionMethods: [ + { + name: "Ancient", + inputs: [{ resource: "any", amount: 12, nodes: 3 }], + outputs: [{ resource: "any", amount: 12 }], + days: 1, + }, + { + name: "Medieval", + inputs: [{ resource: "any", amount: 48, nodes: 6 }], + outputs: [{ resource: "any", amount: 48 }], + days: 1, + }, + { + name: "Modern", + inputs: [{ resource: "any", amount: 108, nodes: 9 }], + outputs: [{ resource: "any", amount: 108 }], + days: 1, + }, + ], + }, +} as const satisfies Record; + +export function getBuildingDefinition( + buildingType: BuildingType, +): BuildingClass { + const building = BUILDINGS[buildingType]; + + if (!building) { + throw new Error(`Building type ${buildingType} not found`); + } + + return building; +} + +export const BUILDING_TYPES = Object.keys(BUILDINGS) as BuildingType[]; diff --git a/src/components/Factory/data/resources.ts b/src/components/Factory/data/resources.ts index c58bf782d..7c535b0b6 100644 --- a/src/components/Factory/data/resources.ts +++ b/src/components/Factory/data/resources.ts @@ -14,6 +14,7 @@ export const RESOURCE_COLORS: Record = { paper: "#FFFFE0", books: "#FFD700", livestock: "#A52A2A", + horses: "#D2B48C", leather: "#DEB887", meat: "#FF6347", coal: "#36454F", @@ -43,6 +44,7 @@ export const RESOURCE_VALUES: Record = { paper: 1, books: 65, livestock: 50, + horses: 100, leather: 10, meat: 8, coal: 2, @@ -155,6 +157,14 @@ export const RESOURCES = { value: RESOURCE_VALUES.livestock, icon: "🐄", }, + horses: { + name: "Horses", + description: + "Horses are valuable animals used for transportation and labor.", + color: RESOURCE_COLORS.horses, + value: RESOURCE_VALUES.horses, + icon: "🐎", + }, leather: { name: "Leather", description: "Leather is a durable material made from animal hides.", @@ -167,7 +177,7 @@ export const RESOURCES = { description: "Meat is a source of food and nutrition.", color: RESOURCE_COLORS.meat, value: RESOURCE_VALUES.meat, - foodValue: 5, + foodValue: 12, icon: "🍖", }, coal: { @@ -206,7 +216,7 @@ export const RESOURCES = { description: "Fish are aquatic animals that can be caught for food.", color: RESOURCE_COLORS.fish, value: RESOURCE_VALUES.fish, - foodValue: 2, + foodValue: 4, icon: "🐟", }, tools: { diff --git a/src/components/Factory/objects/buildings/createBuildingInstance.ts b/src/components/Factory/objects/buildings/createBuildingInstance.ts index 0416ec400..2798b0879 100644 --- a/src/components/Factory/objects/buildings/createBuildingInstance.ts +++ b/src/components/Factory/objects/buildings/createBuildingInstance.ts @@ -1,7 +1,7 @@ +import { getBuildingDefinition } from "../../data/buildings"; import { type BuildingInstance, type BuildingType, - getBuildingType, } from "../../types/buildings"; import { configureBuildingInstanceForMethod } from "../production/configureBuildingInstanceForMethod"; @@ -14,7 +14,7 @@ export function createBuildingInstance( buildingType: BuildingType, productionMethodIndex: number = 0, ): BuildingInstance { - const building = getBuildingType(buildingType); + const building = getBuildingDefinition(buildingType); const productionMethod = building.productionMethods[productionMethodIndex]; diff --git a/src/components/Factory/objects/production/configureBuildingInstanceForMethod.ts b/src/components/Factory/objects/production/configureBuildingInstanceForMethod.ts index a9278f81e..b93b1f8ca 100644 --- a/src/components/Factory/objects/production/configureBuildingInstanceForMethod.ts +++ b/src/components/Factory/objects/production/configureBuildingInstanceForMethod.ts @@ -11,21 +11,128 @@ import type { ProductionMethod } from "../../types/production"; import type { ResourceType } from "../../types/resources"; const STOCKPILE_MULTIPLIER = 10; +const MAX_HANDLES_PER_SIDE = 3; /** - * Distributes handles evenly across all four sides + * Distributes handles in round-robin fashion across available sides */ -function distributeHandlesAcrossSides(count: number): Position[] { +function distributeHandlesRoundRobin( + count: number, + excludeSide?: Position, +): Position[] { + const availableSides = [ + Position.Left, + Position.Right, + Position.Bottom, + Position.Top, + ].filter((side) => side !== excludeSide); + const positions: Position[] = []; - const sides = [Position.Left, Position.Right, Position.Bottom, Position.Top]; for (let i = 0; i < count; i++) { - positions.push(sides[i % 4]); + positions.push(availableSides[i % availableSides.length]); } return positions; } +/** + * Groups resources by type + */ +function groupResourcesByType( + items: T[], +): Map { + const groups = new Map(); + + items.forEach((item) => { + const existing = groups.get(item.resource) || []; + existing.push(item); + groups.set(item.resource, existing); + }); + + return groups; +} + +/** + * Assigns positions to handles with proper spreading logic. + * + * Logic: + * - If multiple different resource types: each type gets its own side + * - If all same type but multiple handles: round-robin distribution + * - Respects MAX_HANDLES_PER_SIDE constraint + */ +function assignHandlePositions( + items: Array<{ resource: ResourceType }>, + excludeSide?: Position, +): Position[] { + if (items.length === 0) return []; + + if (items.length === 1) { + // Single handle goes on default side (not excluded) + const defaultSide = + excludeSide === Position.Right ? Position.Left : Position.Right; + return [defaultSide]; + } + + const groups = groupResourcesByType(items); + + // Strategy 1: Multiple different types - assign each type to a different side + if (groups.size > 1) { + const positions: Position[] = []; + const availableSides = [ + Position.Left, + Position.Right, + Position.Bottom, + Position.Top, + ].filter((side) => side !== excludeSide); + + let sideIndex = 0; + + // Sort groups by size (larger groups first) for better distribution + const sortedGroups = Array.from(groups.entries()).sort( + (a, b) => b[1].length - a[1].length, + ); + + sortedGroups.forEach(([_, groupItems]) => { + // Check if this group exceeds MAX_HANDLES_PER_SIDE + if (groupItems.length > MAX_HANDLES_PER_SIDE) { + // Split large groups across multiple sides + let itemsPlaced = 0; + + while (itemsPlaced < groupItems.length) { + const side = availableSides[sideIndex % availableSides.length]; + const itemsForThisSide = Math.min( + MAX_HANDLES_PER_SIDE, + groupItems.length - itemsPlaced, + ); + + for (let i = 0; i < itemsForThisSide; i++) { + positions.push(side); + } + + itemsPlaced += itemsForThisSide; + sideIndex++; + } + } else { + // Small group fits on one side + const side = availableSides[sideIndex % availableSides.length]; + + for (let i = 0; i < groupItems.length; i++) { + positions.push(side); + } + + sideIndex++; + } + }); + + return positions; + } + + // Strategy 2: All same type - distribute round-robin across all available sides + // This respects MAX_HANDLES_PER_SIDE automatically since we cycle through sides + return distributeHandlesRoundRobin(items.length, excludeSide); +} + interface ConfiguredBuildingData { inputs: BuildingInput[]; outputs: BuildingOutput[]; @@ -39,177 +146,88 @@ interface ConfiguredBuildingData { /** * Configures a building instance for a specific production method. - * Can be used both for creating new buildings and updating existing ones. - * - * @param productionMethod - The production method to configure for - * @param existingBuilding - Optional existing building to preserve values from - * @returns Configuration object that can be merged into a building instance */ export const configureBuildingInstanceForMethod = ( productionMethod: ProductionMethod, existingBuilding?: BuildingInstance, ): ConfiguredBuildingData => { - // Check if we have any non-global outputs - const hasNonGlobalOutputs = productionMethod.outputs.some( - (output) => !isGlobalResource(output.resource), + // Collect non-global inputs and outputs + const nonGlobalInputs = productionMethod.inputs.filter( + (input) => !isGlobalResource(input.resource), ); - // Check if we have any non-global inputs - const hasNonGlobalInputs = productionMethod.inputs.some( - (input) => !isGlobalResource(input.resource), + const nonGlobalOutputs = productionMethod.outputs.filter( + (output) => !isGlobalResource(output.resource), ); - // Count total non-global inputs and outputs - const totalInputNodes = productionMethod.inputs.reduce( - (sum, input) => - isGlobalResource(input.resource) ? sum : sum + (input.nodes ?? 1), - 0, + // Expand inputs/outputs based on nodes count + const expandedInputs = nonGlobalInputs.flatMap((input) => + Array(input.nodes ?? 1).fill({ resource: input.resource }), ); - const totalOutputNodes = productionMethod.outputs.reduce( - (sum, output) => - isGlobalResource(output.resource) ? sum : sum + (output.nodes ?? 1), - 0, + const expandedOutputs = nonGlobalOutputs.flatMap((output) => + Array(output.nodes ?? 1).fill({ resource: output.resource }), ); - // Determine if we should spread handles across all sides - const shouldSpreadInputs = !hasNonGlobalOutputs && totalInputNodes > 1; - const shouldSpreadOutputs = !hasNonGlobalInputs && totalOutputNodes > 1; + const totalInputHandles = expandedInputs.length; + const totalOutputHandles = expandedOutputs.length; + + // Determine layout strategy + let inputPositions: Position[]; + let outputPositions: Position[]; + + if (totalInputHandles === 0 && totalOutputHandles > 0) { + // No inputs, spread outputs across all sides (initially skipping the Left side) + outputPositions = distributeHandlesRoundRobin( + totalOutputHandles, + totalOutputHandles < 4 ? Position.Left : undefined, + ); + inputPositions = []; + } else if (totalOutputHandles === 0 && totalInputHandles > 0) { + // No outputs, spread inputs across all sides + inputPositions = distributeHandlesRoundRobin(totalInputHandles); + outputPositions = []; + } else if (totalInputHandles > totalOutputHandles) { + // More inputs than outputs: outputs on right, spread inputs + outputPositions = expandedOutputs.map(() => Position.Right); + inputPositions = assignHandlePositions(expandedInputs, Position.Right); + } else if (totalOutputHandles > totalInputHandles) { + // More outputs than inputs: inputs on left, spread outputs + inputPositions = expandedInputs.map(() => Position.Left); + outputPositions = assignHandlePositions(expandedOutputs, Position.Left); + } else { + // Equal counts: inputs on left, outputs on right + inputPositions = expandedInputs.map(() => Position.Left); + outputPositions = expandedOutputs.map(() => Position.Right); + } - // Generate inputs from production method + // Generate inputs with assigned positions const inputs: BuildingInput[] = []; let inputPositionIndex = 0; - const inputPositions = shouldSpreadInputs - ? distributeHandlesAcrossSides(totalInputNodes) - : []; - - productionMethod.inputs.forEach((input) => { - // Skip global resources - they don't need physical inputs - if (isGlobalResource(input.resource)) return; + nonGlobalInputs.forEach((input) => { const nodeCount = input.nodes ?? 1; - // If building exists, try to find existing inputs with this resource to preserve positions - const existingInputsForResource = - existingBuilding?.inputs?.filter((i) => i.resource === input.resource) || - []; - - // If we have existing inputs for this resource, preserve their positions - if (existingInputsForResource.length > 0) { - existingInputsForResource.slice(0, nodeCount).forEach((existingInput) => { - inputs.push({ - resource: input.resource, - position: existingInput.position, - }); - if (shouldSpreadInputs) inputPositionIndex++; + for (let i = 0; i < nodeCount; i++) { + inputs.push({ + resource: input.resource, + position: inputPositions[inputPositionIndex++], }); - - // If we need more inputs than we had before, create new ones - const remaining = nodeCount - existingInputsForResource.length; - if (remaining > 0) { - if (shouldSpreadInputs) { - for (let i = 0; i < remaining; i++) { - inputs.push({ - resource: input.resource, - position: inputPositions[inputPositionIndex++], - }); - } - } else { - for (let i = 0; i < remaining; i++) { - inputs.push({ - resource: input.resource, - position: Position.Left, - }); - } - } - } - } else { - // No existing inputs, create new ones - if (shouldSpreadInputs) { - for (let i = 0; i < nodeCount; i++) { - inputs.push({ - resource: input.resource, - position: inputPositions[inputPositionIndex++], - }); - } - } else { - for (let i = 0; i < nodeCount; i++) { - inputs.push({ - resource: input.resource, - position: Position.Left, - }); - } - } } }); - // Generate outputs from production method + // Generate outputs with assigned positions const outputs: BuildingOutput[] = []; let outputPositionIndex = 0; - const outputPositions = shouldSpreadOutputs - ? distributeHandlesAcrossSides(totalOutputNodes) - : []; - - productionMethod.outputs.forEach((output) => { - // Skip global resources - they don't need physical outputs - if (isGlobalResource(output.resource)) return; + nonGlobalOutputs.forEach((output) => { const nodeCount = output.nodes ?? 1; - // If building exists, try to find existing outputs with this resource to preserve positions - const existingOutputsForResource = - existingBuilding?.outputs?.filter( - (o) => o.resource === output.resource, - ) || []; - - // If we have existing outputs for this resource, preserve their positions - if (existingOutputsForResource.length > 0) { - existingOutputsForResource - .slice(0, nodeCount) - .forEach((existingOutput) => { - outputs.push({ - resource: output.resource, - position: existingOutput.position, - }); - if (shouldSpreadOutputs) outputPositionIndex++; - }); - - // If we need more outputs than we had before, create new ones - const remaining = nodeCount - existingOutputsForResource.length; - if (remaining > 0) { - if (shouldSpreadOutputs) { - for (let i = 0; i < remaining; i++) { - outputs.push({ - resource: output.resource, - position: outputPositions[outputPositionIndex++], - }); - } - } else { - for (let i = 0; i < remaining; i++) { - outputs.push({ - resource: output.resource, - position: Position.Right, - }); - } - } - } - } else { - // No existing outputs, create new ones - if (shouldSpreadOutputs) { - for (let i = 0; i < nodeCount; i++) { - outputs.push({ - resource: output.resource, - position: outputPositions[outputPositionIndex++], - }); - } - } else { - for (let i = 0; i < nodeCount; i++) { - outputs.push({ - resource: output.resource, - position: Position.Right, - }); - } - } + for (let i = 0; i < nodeCount; i++) { + outputs.push({ + resource: output.resource, + position: outputPositions[outputPositionIndex++], + }); } }); diff --git a/src/components/Factory/objects/resources/createResourceEdge.ts b/src/components/Factory/objects/resources/createResourceEdge.ts index 59a71b7cb..86290b78a 100644 --- a/src/components/Factory/objects/resources/createResourceEdge.ts +++ b/src/components/Factory/objects/resources/createResourceEdge.ts @@ -9,25 +9,38 @@ export const createResourceEdge = ( targetNodeId: string, resource: ResourceType, reactFlowInstance: ReactFlowInstance, + sourceHandleId?: string | null, + targetHandleId?: string | null, ) => { const { getInternalNode } = reactFlowInstance; const sourceInternalNode = getInternalNode(sourceNodeId); const targetInternalNode = getInternalNode(targetNodeId); - const sourceHandle = sourceInternalNode?.internals.handleBounds?.source?.find( - (handle) => - extractResource(handle.id) === resource || - extractResource(handle.id) === "any", - ); - const targetHandle = targetInternalNode?.internals.handleBounds?.target?.find( - (handle) => - extractResource(handle.id) === resource || - extractResource(handle.id) === "any", - ); - - const sourceHandleId = sourceHandle?.id; - const targetHandleId = targetHandle?.id; + if (!sourceHandleId) { + const sourceHandle = + sourceInternalNode?.internals.handleBounds?.source?.find( + (handle) => + extractResource(handle.id) === resource || + extractResource(handle.id) === "any", + ); + if (sourceHandle?.id) { + sourceHandleId = sourceHandle.id; + } + } + + if (!targetHandleId) { + const targetHandle = + targetInternalNode?.internals.handleBounds?.target?.find( + (handle) => + extractResource(handle.id) === resource || + extractResource(handle.id) === "any", + ); + + if (targetHandle?.id) { + targetHandleId = targetHandle.id; + } + } if (!sourceHandleId || !targetHandleId) { console.error( diff --git a/src/components/Factory/providers/StatisticsProvider.tsx b/src/components/Factory/providers/StatisticsProvider.tsx index 2b16ea3a0..1b30bcde8 100644 --- a/src/components/Factory/providers/StatisticsProvider.tsx +++ b/src/components/Factory/providers/StatisticsProvider.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState } from "react"; -import type { DayStatistics } from "../types/statistics"; +import type { DayStatistics, EdgeStatistics } from "../types/statistics"; interface StatisticsContextType { history: DayStatistics[]; @@ -12,6 +12,10 @@ interface StatisticsContextType { ? T | undefined : never; getLatestDayStats: () => DayStatistics | undefined; + getLatestEdgeStats: ( + edgeId: string, + includeBreakdown?: boolean, + ) => EdgeStatistics[]; resetStatistics: () => void; } @@ -42,6 +46,34 @@ export const StatisticsProvider: React.FC<{ children: React.ReactNode }> = ({ return history[history.length - 1]; }; + /** + * Get edge-specific transfer statistics + * @param edgeId - The edge ID to get stats for + * @param includeBreakdown - If true, also includes stats for "any" edge breakdown (e.g., "edge-123-berries") + */ + const getLatestEdgeStats = ( + edgeId: string, + includeBreakdown = false, + ): EdgeStatistics[] => { + if (history.length === 0) return []; + const latestDay = history[history.length - 1]; + + if (!includeBreakdown) { + const stat = latestDay.edges.get(edgeId); + return stat ? [stat] : []; + } + + // For "any" edges, collect all stats that start with this edge ID + const results: EdgeStatistics[] = []; + latestDay.edges.forEach((stat, key) => { + if (key === edgeId || key.startsWith(`${edgeId}-`)) { + results.push(stat); + } + }); + + return results; + }; + const resetStatistics = () => { setHistory([]); }; @@ -54,6 +86,7 @@ export const StatisticsProvider: React.FC<{ children: React.ReactNode }> = ({ currentDay, getLatestBuildingStats, getLatestDayStats, + getLatestEdgeStats, resetStatistics, }} > diff --git a/src/components/Factory/simulation/helpers/transferResources.ts b/src/components/Factory/simulation/helpers/transferResources.ts index 89facae6a..ffc934469 100644 --- a/src/components/Factory/simulation/helpers/transferResources.ts +++ b/src/components/Factory/simulation/helpers/transferResources.ts @@ -1,10 +1,14 @@ import type { Edge, Node } from "@xyflow/react"; -import { getBuildingInstance } from "../../types/buildings"; +import { + type BuildingInstance, + getBuildingInstance, +} from "../../types/buildings"; import { isResourceData, type ResourceType } from "../../types/resources"; import type { BuildingStatistics, EdgeStatistics, + StockpileChange, } from "../../types/statistics"; export const transferResources = ( @@ -21,6 +25,7 @@ export const transferResources = ( if (!sourceNode || !targetNode) return; + // ✅ Get FRESH building instances each time (after mutations) const sourceBuilding = getBuildingInstance(sourceNode); const targetBuilding = getBuildingInstance(targetNode); @@ -44,192 +49,284 @@ export const transferResources = ( relevantEdges.forEach((edge) => { if (!isResourceData(edge.data)) return; - const resource = edge.data.type; + const edgeResource = edge.data.type; - if (!resource) return; + if (!edgeResource) return; - // ✅ Find source stockpile - check both direct match and "any" with breakdown - let sourceStock = sourceBuilding.stockpile?.find( - (s) => s.resource === resource, - ); + // ✅ Get FRESH building data for each edge (important for multiple edges between same nodes) + const currentSourceBuilding = getBuildingInstance(sourceNode); + const currentTargetBuilding = getBuildingInstance(targetNode); - let sourceIsAny = false; - const sourceAnyStock = sourceBuilding.stockpile?.find( - (s) => s.resource === "any", - ); + if (!currentSourceBuilding || !currentTargetBuilding) return; - // If no direct match, check if source has "any" stockpile with this resource in breakdown - if (sourceAnyStock?.breakdown?.has(resource)) { - sourceStock = sourceAnyStock; - sourceIsAny = true; + // ✅ Determine which resources to transfer based on edge type + const resourcesToTransfer: ResourceType[] = []; + + if (edgeResource === "any") { + // For "any" edge, transfer all resources in the "any" stockpile breakdown + const sourceAnyStock = currentSourceBuilding.stockpile?.find( + (s) => s.resource === "any", + ); + if (sourceAnyStock?.breakdown) { + resourcesToTransfer.push(...sourceAnyStock.breakdown.keys()); + } + } else { + // For specific resource edge, transfer just that resource + resourcesToTransfer.push(edgeResource); } - // ✅ Find target stockpile - const targetStock = targetBuilding.stockpile?.find( - (s) => s.resource === resource || s.resource === "any", - ); + // ✅ For "any" edges, transfer ALL of each resource (no limit per resource) + // For specific edges, respect the transferAmount + if (edgeResource === "any") { + // Transfer unlimited for each resource in breakdown + resourcesToTransfer.forEach((resource) => { + const result = transferSingleResource( + sourceNode, + targetNode, + resource, + undefined, // No limit - transfer everything available + ); - if (!sourceStock || !targetStock) return; + if (result) { + // Track edge statistics + const edgeStatKey = `${edge.id}-${resource}`; + edgeStats.set(edgeStatKey, { + transferred: result.amount, + resource: resource, + }); - // ✅ Calculate available amount from source - let availableFromSource: number; - if (sourceIsAny && sourceStock.breakdown) { - // Source is "any" - get amount from breakdown - availableFromSource = sourceStock.breakdown.get(resource) || 0; + // Track statistics + trackStockpileChange(sourceStats.stockpileChanges, resource, { + removed: result.amount, + added: 0, + }); + trackStockpileChange(targetStats.stockpileChanges, resource, { + removed: 0, + added: result.amount, + }); + } + }); } else { - // Source is specific resource - availableFromSource = sourceStock.amount; + // For specific resource edges, respect transfer amount + resourcesToTransfer.forEach((resource) => { + const result = transferSingleResource( + sourceNode, + targetNode, + resource, + transferAmount, + ); + + if (result) { + // Track edge statistics + edgeStats.set(edge.id, { + transferred: result.amount, + resource: resource, + }); + + // Track statistics + trackStockpileChange(sourceStats.stockpileChanges, resource, { + removed: result.amount, + added: 0, + }); + trackStockpileChange(targetStats.stockpileChanges, resource, { + removed: 0, + added: result.amount, + }); + } + }); } + }); +}; - if (availableFromSource === 0) return; +/** + * Helper to find available amount for a resource in stockpile. + * Checks both direct stockpile and "any" stockpile breakdown. + */ +function findAvailableAmount( + building: BuildingInstance, + resource: ResourceType, +): { amount: number; isAny: boolean } { + // Check direct stockpile first + const directStock = building.stockpile?.find((s) => s.resource === resource); + if (directStock && directStock.amount > 0) { + return { amount: directStock.amount, isAny: false }; + } - // Calculate transfer amount - let actualTransferAmount: number; + // Check "any" stockpile breakdown + const anyStock = building.stockpile?.find((s) => s.resource === "any"); + if (anyStock?.breakdown?.has(resource)) { + const amount = anyStock.breakdown.get(resource) || 0; + return { amount, isAny: true }; + } - if (transferAmount !== undefined) { - const availableSpaceInTarget = targetStock.maxAmount - targetStock.amount; - actualTransferAmount = Math.min( - transferAmount, - availableFromSource, - availableSpaceInTarget, - ); - } else { - // Original behavior: transfer all available - const availableSpaceInTarget = targetStock.maxAmount - targetStock.amount; - actualTransferAmount = Math.min( - availableFromSource, - availableSpaceInTarget, - ); - } + return { amount: 0, isAny: false }; +} - if (actualTransferAmount > 0) { - // Track edge statistics - edgeStats.set(edge.id, { - transferred: actualTransferAmount, - resource: resource, - }); +/** + * Helper to find available space for a resource in stockpile. + * Checks both direct stockpile and "any" stockpile. + */ +function findAvailableSpace( + building: BuildingInstance, + resource: ResourceType, +): { space: number; isAny: boolean } { + // Check direct stockpile first + const directStock = building.stockpile?.find((s) => s.resource === resource); + if (directStock) { + return { + space: directStock.maxAmount - directStock.amount, + isAny: false, + }; + } - // Track source stockpile change (removed) - const sourceChange = sourceStats.stockpileChanges.find( - (c) => c.resource === resource, - ); - if (sourceChange) { - sourceChange.removed += actualTransferAmount; - sourceChange.net = sourceChange.added - sourceChange.removed; - } else { - sourceStats.stockpileChanges.push({ - resource: resource, - removed: actualTransferAmount, - added: 0, - net: -actualTransferAmount, - }); - } + // Check "any" stockpile + const anyStock = building.stockpile?.find((s) => s.resource === "any"); + if (anyStock) { + return { + space: anyStock.maxAmount - anyStock.amount, + isAny: true, + }; + } - // Track target stockpile change (added) - const targetChange = targetStats.stockpileChanges.find( - (c) => c.resource === resource, - ); - if (targetChange) { - targetChange.added += actualTransferAmount; - targetChange.net = targetChange.added - targetChange.removed; - } else { - targetStats.stockpileChanges.push({ - resource: resource, - removed: 0, - added: actualTransferAmount, - net: actualTransferAmount, - }); - } + return { space: 0, isAny: false }; +} - // ✅ Update source stockpile - if (sourceIsAny && sourceStock.breakdown) { - // Source is "any" - update breakdown - const breakdown = new Map(sourceStock.breakdown); - const currentAmount = breakdown.get(resource as ResourceType) || 0; - const newAmount = currentAmount - actualTransferAmount; - - if (newAmount <= 0) { - breakdown.delete(resource as ResourceType); - } else { - breakdown.set(resource as ResourceType, newAmount); - } +/** + * Transfer a single resource from source to target. + * Returns the amount transferred or null if no transfer occurred. + */ +function transferSingleResource( + sourceNode: Node, + targetNode: Node, + resource: ResourceType, + maxTransferAmount?: number, +): { amount: number } | null { + // ✅ Get fresh building data for accurate amounts + const sourceBuilding = getBuildingInstance(sourceNode); + const targetBuilding = getBuildingInstance(targetNode); - const updatedSourceBuilding = { - ...sourceBuilding, - stockpile: sourceBuilding.stockpile?.map((s) => - s.resource === "any" - ? { - ...s, - amount: s.amount - actualTransferAmount, - breakdown, - } - : s, - ), - }; - - sourceNode.data = { - ...sourceNode.data, - buildingInstance: updatedSourceBuilding, - }; - } else { - // Source is specific resource - const updatedSourceBuilding = { - ...sourceBuilding, - stockpile: sourceBuilding.stockpile?.map((s) => - s.resource === resource - ? { ...s, amount: s.amount - actualTransferAmount } - : s, - ), - }; - - sourceNode.data = { - ...sourceNode.data, - buildingInstance: updatedSourceBuilding, - }; - } + if (!sourceBuilding || !targetBuilding) return null; - // ✅ Update target stockpile - if (targetStock.resource === "any") { - const breakdown = new Map(targetStock.breakdown || new Map()); - const currentAmount = breakdown.get(resource as ResourceType) || 0; - breakdown.set( - resource as ResourceType, - currentAmount + actualTransferAmount, - ); + const sourceInfo = findAvailableAmount(sourceBuilding, resource); + const targetInfo = findAvailableSpace(targetBuilding, resource); - const updatedTargetBuilding = { - ...targetBuilding, - stockpile: targetBuilding.stockpile?.map((s) => - s.resource === "any" - ? { - ...s, - amount: s.amount + actualTransferAmount, - breakdown, - } - : s, - ), - }; - - targetNode.data = { - ...targetNode.data, - buildingInstance: updatedTargetBuilding, - }; - } else { - // Regular stockpile transfer - const updatedTargetBuilding = { - ...targetBuilding, - stockpile: targetBuilding.stockpile?.map((s) => - s.resource === resource - ? { ...s, amount: s.amount + actualTransferAmount } - : s, - ), - }; - - targetNode.data = { - ...targetNode.data, - buildingInstance: updatedTargetBuilding, - }; - } + if (sourceInfo.amount === 0 || targetInfo.space === 0) { + return null; + } + + const actualTransferAmount = Math.min( + maxTransferAmount ?? sourceInfo.amount, + sourceInfo.amount, + targetInfo.space, + ); + + if (actualTransferAmount <= 0) { + return null; + } + + // Update source stockpile + updateStockpile( + sourceNode, + sourceBuilding, + resource, + -actualTransferAmount, + sourceInfo.isAny, + ); + + // Update target stockpile (get fresh target building after source update) + const freshTargetBuilding = getBuildingInstance(targetNode); + if (!freshTargetBuilding) return null; + + updateStockpile( + targetNode, + freshTargetBuilding, + resource, + actualTransferAmount, + targetInfo.isAny, + ); + + return { amount: actualTransferAmount }; +} + +/** + * Update a stockpile by adding/removing an amount. + * Handles both direct stockpile and "any" stockpile breakdown. + */ +function updateStockpile( + node: Node, + building: BuildingInstance, + resource: ResourceType, + delta: number, + isAny: boolean, +) { + if (isAny) { + // Update "any" stockpile breakdown + const anyStock = building.stockpile?.find((s) => s.resource === "any"); + if (!anyStock) return; + + const breakdown = new Map(anyStock.breakdown || new Map()); + const currentAmount = breakdown.get(resource) || 0; + const newAmount = currentAmount + delta; + + if (newAmount <= 0) { + breakdown.delete(resource); + } else { + breakdown.set(resource, newAmount); } - }); -}; + + const updatedBuilding = { + ...building, + stockpile: building.stockpile?.map((s) => + s.resource === "any" + ? { + ...s, + amount: s.amount + delta, + breakdown, + } + : s, + ), + }; + + node.data = { + ...node.data, + buildingInstance: updatedBuilding, + }; + } else { + // Update direct stockpile + const updatedBuilding = { + ...building, + stockpile: building.stockpile?.map((s) => + s.resource === resource ? { ...s, amount: s.amount + delta } : s, + ), + }; + + node.data = { + ...node.data, + buildingInstance: updatedBuilding, + }; + } +} + +/** + * Track stockpile changes in statistics + */ +function trackStockpileChange( + changes: StockpileChange[], + resource: ResourceType, + amounts: { removed: number; added: number }, +) { + const existing = changes.find((c) => c.resource === resource); + + if (existing) { + existing.removed += amounts.removed; + existing.added += amounts.added; + existing.net = existing.added - existing.removed; + } else { + changes.push({ + resource, + removed: amounts.removed, + added: amounts.added, + net: amounts.added - amounts.removed, + }); + } +} diff --git a/src/components/Factory/simulation/helpers/transferResourcesEvenlyDownstream.ts b/src/components/Factory/simulation/helpers/transferResourcesEvenlyDownstream.ts index 49a695f36..ed160e243 100644 --- a/src/components/Factory/simulation/helpers/transferResourcesEvenlyDownstream.ts +++ b/src/components/Factory/simulation/helpers/transferResourcesEvenlyDownstream.ts @@ -11,8 +11,7 @@ import { transferResources } from "./transferResources"; /** * Transfers resources from a node to all its downstream neighbors, * splitting evenly when multiple targets want the same resource. - * Handles remainders by distributing them round-robin. - * Supports "any" stockpiles by checking breakdown for available resources. + * Handles "any" type edges and stockpiles correctly. */ export const transferResourcesEvenlyDownstream = ( sourceNodeId: string, @@ -30,36 +29,58 @@ export const transferResourcesEvenlyDownstream = ( const building = getBuildingInstance(sourceNode); if (!building) return; - // ✅ Map: resource -> array of target node IDs that want this resource - const resourceTargets = new Map(); + // ✅ Step 1: Determine ALL resources available at source + const availableResources = new Set(); - downstreamNodes.forEach((neighborId) => { - const relevantEdges = edges.filter( - (e) => e.source === sourceNodeId && e.target === neighborId, - ); + building.stockpile?.forEach((stock) => { + if (stock.resource === "any" && stock.breakdown) { + // Add all resources in breakdown + stock.breakdown.forEach((amount, resource) => { + if (amount > 0) { + availableResources.add(resource); + } + }); + } else if (stock.amount > 0) { + // Add direct stockpile resource + availableResources.add(stock.resource); + } + }); - relevantEdges.forEach((edge) => { - if (!isResourceData(edge.data)) return; + // ✅ Step 2: For each available resource, determine which downstream nodes want it + const resourceTargets = new Map(); - const resource = edge.data.type; + availableResources.forEach((resource) => { + const targets: string[] = []; - if (!resource) return; + downstreamNodes.forEach((neighborId) => { + const relevantEdges = edges.filter( + (e) => e.source === sourceNodeId && e.target === neighborId, + ); - if (!resourceTargets.has(resource)) { - resourceTargets.set(resource, []); - } + // Check if any edge can carry this resource + const hasEdgeForResource = relevantEdges.some((edge) => { + if (!isResourceData(edge.data)) return false; + const edgeResource = edge.data.type; + // Edge can carry if it's the specific resource OR if it's "any" + return edgeResource === resource || edgeResource === "any"; + }); - resourceTargets.get(resource)!.push(neighborId); + if (hasEdgeForResource) { + targets.push(neighborId); + } }); + + if (targets.length > 0) { + resourceTargets.set(resource, targets); + } }); - // ✅ For each resource type requested by downstream nodes + // ✅ Step 3: For each resource, calculate fair split among targets resourceTargets.forEach((targets, resource) => { - // Check if source has this resource (either directly or in "any" breakdown) + // Get available amount (from direct stockpile or "any" breakdown) const directStock = building.stockpile?.find( (s) => s.resource === resource, ); - const anyStock = building.stockpile?.find((s) => s.resource === "any"); const amountInBreakdown = anyStock?.breakdown?.get(resource) || 0; @@ -67,14 +88,13 @@ export const transferResourcesEvenlyDownstream = ( ? directStock.amount : amountInBreakdown; - // Skip if no resources available if (availableAmount === 0) return; const splitCount = targets.length; const baseAmount = Math.floor(availableAmount / splitCount); const remainder = availableAmount % splitCount; - // ✅ Calculate capacity for each target (only if they can accept this resource type) + // ✅ Calculate capacity for each target const targetCapacities = new Map(); targets.forEach((targetId) => { @@ -84,7 +104,7 @@ export const transferResourcesEvenlyDownstream = ( const targetBuilding = getBuildingInstance(targetNode); if (!targetBuilding) return; - // ✅ Find a stockpile that can accept this specific resource + // Find a stockpile that can accept this specific resource const targetStock = targetBuilding.stockpile?.find( (s) => s.resource === resource || s.resource === "any", ); @@ -93,19 +113,16 @@ export const transferResourcesEvenlyDownstream = ( const availableSpace = targetStock.maxAmount - targetStock.amount; targetCapacities.set(targetId, availableSpace); } else { - // Target cannot accept this resource type - capacity is 0 targetCapacities.set(targetId, 0); } }); - // ✅ First pass: allocate base amounts (respecting capacity) + // ✅ Allocate base amounts + remainder (round-robin) const allocations = new Map(); let totalAllocated = 0; targets.forEach((targetId, index) => { const capacity = targetCapacities.get(targetId) || 0; - - // Base amount + 1 extra for first 'remainder' targets (round-robin) let allocation = baseAmount + (index < remainder ? 1 : 0); allocation = Math.min(allocation, capacity); @@ -113,11 +130,10 @@ export const transferResourcesEvenlyDownstream = ( totalAllocated += allocation; }); - // ✅ Second pass: redistribute leftovers if some targets couldn't take their full share + // ✅ Redistribute leftovers if some targets hit capacity const leftover = availableAmount - totalAllocated; if (leftover > 0) { - // Try to give leftovers to targets that still have capacity let remainingLeftover = leftover; for (const [targetId, currentAllocation] of allocations) { @@ -134,7 +150,7 @@ export const transferResourcesEvenlyDownstream = ( } } - // ✅ Perform transfers with calculated allocations + // ✅ Step 4: Perform transfers with calculated allocations allocations.forEach((amount, targetId) => { if (amount > 0) { transferResources( diff --git a/src/components/Factory/types/buildings.ts b/src/components/Factory/types/buildings.ts index d358a21a3..42f3b0752 100644 --- a/src/components/Factory/types/buildings.ts +++ b/src/components/Factory/types/buildings.ts @@ -1,9 +1,40 @@ import type { Node, Position } from "@xyflow/react"; -import { BUILDINGS } from "../data/buildings"; +import type { IconName } from "@/components/ui/icon"; + import type { ProductionMethod, ProductionState } from "./production"; import type { ResourceType } from "./resources"; +export type BuildingType = + | "firepit" + | "tradingpost" + | "marketplace" + | "library" + | "storagepit" + | "well" + | "woodcutter" + | "quarry" + | "farm" + | "sawmill" + | "papermill" + | "pasture" + | "butchery" + | "bookbinder" + | "mill" + | "kiln" + | "bakery" + | "bank" + | "foraging" + | "fishing" + | "hunting" + | "granary" + | "smelter" + | "toolsmith" + | "mine" + | "mint" + | "splitter" + | "merger"; + export type BuildingInput = { resource: ResourceType; position?: Position; @@ -21,12 +52,22 @@ export type Stockpile = { breakdown?: Map; }; -export type BuildingCategory = - | "special" - | "production" - | "refining" - | "utility" - | "storage"; +export type BuildingCategoryDefinition = { + type: string; + label: string; + icon: IconName; +}; + +export const BUILDING_CATEGORIES: BuildingCategoryDefinition[] = [ + { type: "special", label: "Special", icon: "Star" }, + { type: "logistics", label: "Logistics", icon: "Truck" }, + { type: "production", label: "Production", icon: "Hammer" }, + { type: "refining", label: "Refining", icon: "Factory" }, + { type: "services", label: "Services", icon: "Store" }, + { type: "storage", label: "Storage", icon: "Package" }, +]; + +export type BuildingCategory = (typeof BUILDING_CATEGORIES)[number]["type"]; export interface BuildingClass { name: string; @@ -55,16 +96,6 @@ 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; -} - function isBuildingInstance(data: any): data is BuildingInstance { return ( data !== null && @@ -114,6 +145,3 @@ export function getBuildingInstance( return null; } - -export type BuildingType = keyof typeof BUILDINGS; -export const BUILDING_TYPES = Object.keys(BUILDINGS) as BuildingType[]; diff --git a/src/components/Factory/types/resources.ts b/src/components/Factory/types/resources.ts index ba784b529..6057a1ed4 100644 --- a/src/components/Factory/types/resources.ts +++ b/src/components/Factory/types/resources.ts @@ -22,6 +22,7 @@ const RESOURCE_TYPES = [ "paper", "books", "livestock", + "horses", "leather", "meat", "coins",