From 1cb3e585b112655ba6e939e5e4285eea37d0ce67 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Sun, 15 Feb 2026 20:58:32 -0800 Subject: [PATCH] hackdays: Add Storage Pit --- .../Factory/Canvas/Edges/ResourceEdge.tsx | 71 ++++++++--- .../Factory/Canvas/callbacks/onConnect.ts | 13 +- .../Factory/Context/ResourceContext.tsx | 29 ++++- .../components/TransportationFeedback.tsx | 28 +++++ src/components/Factory/data/buildings.ts | 29 ++++- .../helpers/processSpecialBuilding.ts | 47 ++++++++ .../simulation/helpers/transferResources.ts | 111 ++++++++++++++---- .../transferResourcesEvenlyDownstream.ts | 41 +++++-- src/components/Factory/types/resources.ts | 4 +- src/components/Factory/utils/bezier.ts | 59 ++++++++++ 10 files changed, 373 insertions(+), 59 deletions(-) create mode 100644 src/components/Factory/components/TransportationFeedback.tsx create mode 100644 src/components/Factory/utils/bezier.ts diff --git a/src/components/Factory/Canvas/Edges/ResourceEdge.tsx b/src/components/Factory/Canvas/Edges/ResourceEdge.tsx index 3cb1d0404..635947f12 100644 --- a/src/components/Factory/Canvas/Edges/ResourceEdge.tsx +++ b/src/components/Factory/Canvas/Edges/ResourceEdge.tsx @@ -1,11 +1,19 @@ import type { EdgeProps } from "@xyflow/react"; -import { BaseEdge, getBezierPath, useEdges } from "@xyflow/react"; +import { + BaseEdge, + EdgeLabelRenderer, + getBezierPath, + useEdges, +} from "@xyflow/react"; import { useEffect } from "react"; import { useContextPanel } from "@/providers/ContextPanelProvider"; +import { TransportationFeedback } from "../../components/TransportationFeedback"; import ResourceContext from "../../Context/ResourceContext"; +import { useStatistics } from "../../providers/StatisticProvider"; import { isResourceData } from "../../types/resources"; +import { getBezierMidpointAngle } from "../../utils/bezier"; const ResourceEdge = ({ id, @@ -29,8 +37,14 @@ const ResourceEdge = ({ clearContent, setOpen: setContextPanelOpen, } = useContextPanel(); + const { getLatestBuildingStats } = useStatistics(); - const [edgePath] = getBezierPath({ + const sourceStats = getLatestBuildingStats(source); + const resourcesTransferred = sourceStats?.stockpileChanges.filter( + (c) => c.removed > 0, + ); + + const [edgePath, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, @@ -74,19 +88,48 @@ const ResourceEdge = ({ setContextPanelOpen, ]); + const labelRotation = getBezierMidpointAngle( + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + ); + return ( - + <> + + {resourcesTransferred && ( + +
+
+ +
+
+
+ )} + ); }; diff --git a/src/components/Factory/Canvas/callbacks/onConnect.ts b/src/components/Factory/Canvas/callbacks/onConnect.ts index 6eb924119..b1553e1b3 100644 --- a/src/components/Factory/Canvas/callbacks/onConnect.ts +++ b/src/components/Factory/Canvas/callbacks/onConnect.ts @@ -1,6 +1,7 @@ import type { Connection, Edge } from "@xyflow/react"; import { RESOURCES } from "../../data/resources"; +import type { ResourceType } from "../../types/resources"; import { extractResource } from "../../utils/string"; export const createOnConnect = ( @@ -20,7 +21,15 @@ export const createOnConnect = ( return; } - const edgeResource = sourceResource ?? targetResource; + let edgeResource: ResourceType | null = null; + + if (sourceResource === "any" && targetResource !== "any") { + edgeResource = targetResource; + } else if (targetResource === "any" && sourceResource !== "any") { + edgeResource = sourceResource; + } else if (sourceResource === targetResource) { + edgeResource = sourceResource; + } if (!edgeResource) { console.error("Invalid resource type:", edgeResource); @@ -31,7 +40,7 @@ export const createOnConnect = ( ...connection, id: `${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}`, type: "resourceEdge", - data: { ...RESOURCES[edgeResource] }, + data: { ...RESOURCES[edgeResource], type: edgeResource }, animated: true, }; diff --git a/src/components/Factory/Context/ResourceContext.tsx b/src/components/Factory/Context/ResourceContext.tsx index 834dbe243..be5d501fc 100644 --- a/src/components/Factory/Context/ResourceContext.tsx +++ b/src/components/Factory/Context/ResourceContext.tsx @@ -2,6 +2,8 @@ import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Separator } from "@/components/ui/separator"; import { Text } from "@/components/ui/typography"; +import { RESOURCES } from "../data/resources"; +import { useStatistics } from "../providers/StatisticProvider"; import type { Resource } from "../types/resources"; interface ResourceContextProps { @@ -15,7 +17,15 @@ const ResourceContext = ({ sourceNodeId, targetNodeId, }: ResourceContextProps) => { - const { name, description, color, icon, value } = resource; + 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, + ); return ( - + Value: 💰 {value} + {foodValue && / 🍎 {foodValue}} @@ -63,6 +74,20 @@ const ResourceContext = ({ )} + + Transported Yesterday: + + {resourcesTransferred && resourcesTransferred.length > 0 ? ( + resourcesTransferred.map((c, index) => ( + + {RESOURCES[c.resource].icon} {c.removed} {name} + + )) + ) : ( + + No recent transfers + + )} ); diff --git a/src/components/Factory/components/TransportationFeedback.tsx b/src/components/Factory/components/TransportationFeedback.tsx new file mode 100644 index 000000000..2c6dc778e --- /dev/null +++ b/src/components/Factory/components/TransportationFeedback.tsx @@ -0,0 +1,28 @@ +import { InlineStack } from "@/components/ui/layout"; + +import { RESOURCES } from "../data/resources"; +import type { StockpileChange } from "../types/statistics"; + +interface TransportationFeedbackProps { + resourcesTransferred?: StockpileChange[]; +} + +export const TransportationFeedback = ({ + resourcesTransferred, +}: TransportationFeedbackProps) => { + if (!resourcesTransferred || resourcesTransferred.length === 0) { + return null; + } + + return ( + + {resourcesTransferred.map((c, index) => ( +

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

+ ))} +
+ ); +}; diff --git a/src/components/Factory/data/buildings.ts b/src/components/Factory/data/buildings.ts index 4c7c02855..e7c971bd3 100644 --- a/src/components/Factory/data/buildings.ts +++ b/src/components/Factory/data/buildings.ts @@ -1,6 +1,11 @@ import type { BuildingClass } from "../types/buildings"; -export const SPECIAL_BUILDINGS = ["firepit", "marketplace", "granary"]; // Buildings with special processing logic that doesn't fit the standard production model +export const SPECIAL_BUILDINGS = [ + "firepit", + "marketplace", + "storagepit", + "granary", +]; // Buildings with special processing logic that doesn't fit the standard production model export const BUILDINGS: Record = { firepit: { @@ -64,6 +69,28 @@ export const BUILDINGS: Record = { }, ], }, + storagepit: { + name: "Storage Pit", + icon: "📦", + description: "Stores anything!", + cost: 0, + color: "#A9A9A9", + category: "storage", + productionMethods: [ + { + name: "Fill", + inputs: [{ resource: "any", amount: 100, nodes: 4 }], + outputs: [], + days: 1, + }, + { + name: "Empty", + inputs: [], + outputs: [{ resource: "any", amount: 100, nodes: 4 }], + days: 1, + }, + ], + }, well: { name: "Well", icon: "💧", diff --git a/src/components/Factory/simulation/helpers/processSpecialBuilding.ts b/src/components/Factory/simulation/helpers/processSpecialBuilding.ts index 4e5124299..95f970348 100644 --- a/src/components/Factory/simulation/helpers/processSpecialBuilding.ts +++ b/src/components/Factory/simulation/helpers/processSpecialBuilding.ts @@ -198,4 +198,51 @@ export const processSpecialBuilding = ( buildingInstance: updatedBuilding, }; } + + // Storage Pit: + // Production Method "Fill" - it only stockpiles input resources + // Production Method "Empty" - it only outputs resources from its stockpile + // Actual transfer of resources is done prior to this step, so here we just manage the production state + if (building.type === "storagepit") { + const storingMethod = building.productionMethod.name === "Fill"; + const retrievingMethod = building.productionMethod.name === "Empty"; + + const storagePitStats = buildingStats.get(node.id)!; + + if (!building.stockpile || building.stockpile.length === 0) { + node.data = { + ...node.data, + buildingInstance: { + ...building, + productionState: { progress: 0, status: "idle" }, + }, + }; + return; + } + + if (storingMethod) { + node.data = { + ...node.data, + buildingInstance: { + ...building, + productionState: { progress: 0, status: "idle" }, + }, + }; + } else if (retrievingMethod) { + const hasTransferredResources = storagePitStats.stockpileChanges.some( + (c) => c.removed > 0, + ); + + node.data = { + ...node.data, + buildingInstance: { + ...building, + productionState: { + progress: hasTransferredResources ? 1 : 0, + status: hasTransferredResources ? "complete" : "idle", + }, + }, + }; + } + } }; diff --git a/src/components/Factory/simulation/helpers/transferResources.ts b/src/components/Factory/simulation/helpers/transferResources.ts index 4e8faccf1..89facae6a 100644 --- a/src/components/Factory/simulation/helpers/transferResources.ts +++ b/src/components/Factory/simulation/helpers/transferResources.ts @@ -1,11 +1,11 @@ import type { Edge, Node } from "@xyflow/react"; import { getBuildingInstance } from "../../types/buildings"; +import { isResourceData, type ResourceType } from "../../types/resources"; import type { BuildingStatistics, EdgeStatistics, } from "../../types/statistics"; -import { extractResource } from "../../utils/string"; export const transferResources = ( sourceNodeId: string, @@ -43,21 +43,46 @@ export const transferResources = ( ); relevantEdges.forEach((edge) => { - // Extract resource from edge handle - const resource = - extractResource(edge.sourceHandle) || extractResource(edge.targetHandle); + if (!isResourceData(edge.data)) return; + const resource = edge.data.type; + if (!resource) return; - // Find stockpiles - const sourceStock = sourceBuilding.stockpile?.find( + // ✅ Find source stockpile - check both direct match and "any" with breakdown + let sourceStock = sourceBuilding.stockpile?.find( (s) => s.resource === resource, ); + + let sourceIsAny = false; + const sourceAnyStock = sourceBuilding.stockpile?.find( + (s) => s.resource === "any", + ); + + // If no direct match, check if source has "any" stockpile with this resource in breakdown + if (sourceAnyStock?.breakdown?.has(resource)) { + sourceStock = sourceAnyStock; + sourceIsAny = true; + } + + // ✅ Find target stockpile const targetStock = targetBuilding.stockpile?.find( (s) => s.resource === resource || s.resource === "any", ); if (!sourceStock || !targetStock) return; + // ✅ 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; + } else { + // Source is specific resource + availableFromSource = sourceStock.amount; + } + + if (availableFromSource === 0) return; + // Calculate transfer amount let actualTransferAmount: number; @@ -65,12 +90,11 @@ export const transferResources = ( const availableSpaceInTarget = targetStock.maxAmount - targetStock.amount; actualTransferAmount = Math.min( transferAmount, - sourceStock.amount, + availableFromSource, availableSpaceInTarget, ); } else { // Original behavior: transfer all available - const availableFromSource = sourceStock.amount; const availableSpaceInTarget = targetStock.maxAmount - targetStock.amount; actualTransferAmount = Math.min( availableFromSource, @@ -117,26 +141,61 @@ export const transferResources = ( }); } - // Update source stockpile - preserve buildingInstance structure - const updatedSourceBuilding = { - ...sourceBuilding, - stockpile: sourceBuilding.stockpile?.map((s) => - s.resource === resource - ? { ...s, amount: s.amount - actualTransferAmount } - : s, - ), - }; - - sourceNode.data = { - ...sourceNode.data, - buildingInstance: updatedSourceBuilding, - }; - - // Update target stockpile - preserve buildingInstance structure + // ✅ 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); + } + + 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, + }; + } + + // ✅ Update target stockpile if (targetStock.resource === "any") { const breakdown = new Map(targetStock.breakdown || new Map()); - const currentAmount = breakdown.get(resource) || 0; - breakdown.set(resource, currentAmount + actualTransferAmount); + const currentAmount = breakdown.get(resource as ResourceType) || 0; + breakdown.set( + resource as ResourceType, + currentAmount + actualTransferAmount, + ); const updatedTargetBuilding = { ...targetBuilding, diff --git a/src/components/Factory/simulation/helpers/transferResourcesEvenlyDownstream.ts b/src/components/Factory/simulation/helpers/transferResourcesEvenlyDownstream.ts index 630b4edfe..49a695f36 100644 --- a/src/components/Factory/simulation/helpers/transferResourcesEvenlyDownstream.ts +++ b/src/components/Factory/simulation/helpers/transferResourcesEvenlyDownstream.ts @@ -1,18 +1,18 @@ import type { Edge, Node } from "@xyflow/react"; import { getBuildingInstance } from "../../types/buildings"; -import type { ResourceType } from "../../types/resources"; +import { isResourceData, type ResourceType } from "../../types/resources"; import type { BuildingStatistics, EdgeStatistics, } from "../../types/statistics"; -import { extractResource } from "../../utils/string"; 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. */ export const transferResourcesEvenlyDownstream = ( sourceNodeId: string, @@ -30,7 +30,7 @@ export const transferResourcesEvenlyDownstream = ( const building = getBuildingInstance(sourceNode); if (!building) return; - // ✅ Map: resource -> array of target node IDs + // ✅ Map: resource -> array of target node IDs that want this resource const resourceTargets = new Map(); downstreamNodes.forEach((neighborId) => { @@ -39,9 +39,10 @@ export const transferResourcesEvenlyDownstream = ( ); relevantEdges.forEach((edge) => { - const resource = - extractResource(edge.sourceHandle) || - extractResource(edge.targetHandle); + if (!isResourceData(edge.data)) return; + + const resource = edge.data.type; + if (!resource) return; if (!resourceTargets.has(resource)) { @@ -52,18 +53,28 @@ export const transferResourcesEvenlyDownstream = ( }); }); - // ✅ For each resource, calculate splits and handle remainders + // ✅ For each resource type requested by downstream nodes resourceTargets.forEach((targets, resource) => { - const sourceStock = building.stockpile?.find( + // Check if source has this resource (either directly or in "any" breakdown) + const directStock = building.stockpile?.find( (s) => s.resource === resource, ); - if (!sourceStock || sourceStock.amount === 0) return; + + const anyStock = building.stockpile?.find((s) => s.resource === "any"); + const amountInBreakdown = anyStock?.breakdown?.get(resource) || 0; + + const availableAmount = directStock + ? directStock.amount + : amountInBreakdown; + + // Skip if no resources available + if (availableAmount === 0) return; const splitCount = targets.length; - const baseAmount = Math.floor(sourceStock.amount / splitCount); - const remainder = sourceStock.amount % splitCount; + const baseAmount = Math.floor(availableAmount / splitCount); + const remainder = availableAmount % splitCount; - // ✅ Calculate capacity for each target + // ✅ Calculate capacity for each target (only if they can accept this resource type) const targetCapacities = new Map(); targets.forEach((targetId) => { @@ -73,6 +84,7 @@ export const transferResourcesEvenlyDownstream = ( const targetBuilding = getBuildingInstance(targetNode); if (!targetBuilding) return; + // ✅ Find a stockpile that can accept this specific resource const targetStock = targetBuilding.stockpile?.find( (s) => s.resource === resource || s.resource === "any", ); @@ -80,6 +92,9 @@ export const transferResourcesEvenlyDownstream = ( if (targetStock) { const availableSpace = targetStock.maxAmount - targetStock.amount; targetCapacities.set(targetId, availableSpace); + } else { + // Target cannot accept this resource type - capacity is 0 + targetCapacities.set(targetId, 0); } }); @@ -99,7 +114,7 @@ export const transferResourcesEvenlyDownstream = ( }); // ✅ Second pass: redistribute leftovers if some targets couldn't take their full share - const leftover = sourceStock.amount - totalAllocated; + const leftover = availableAmount - totalAllocated; if (leftover > 0) { // Try to give leftovers to targets that still have capacity diff --git a/src/components/Factory/types/resources.ts b/src/components/Factory/types/resources.ts index dbbce7d4e..ba784b529 100644 --- a/src/components/Factory/types/resources.ts +++ b/src/components/Factory/types/resources.ts @@ -6,6 +6,7 @@ export interface Resource { value: number; foodValue?: number; global?: boolean; + type?: ResourceType; } const RESOURCE_TYPES = [ @@ -52,6 +53,7 @@ export function isResourceData(data: any): data is Resource { typeof data.description === "string" && typeof data.icon === "string" && typeof data.value === "number" && - (data.global === undefined || typeof data.global === "boolean") + (data.global === undefined || typeof data.global === "boolean") && + (data.type === undefined || isResourceType(data.type)) ); } diff --git a/src/components/Factory/utils/bezier.ts b/src/components/Factory/utils/bezier.ts new file mode 100644 index 000000000..a29ddcf91 --- /dev/null +++ b/src/components/Factory/utils/bezier.ts @@ -0,0 +1,59 @@ +/** + * Calculate the angle of a cubic Bezier curve at t=0.5 (midpoint) + * Bezier formula: B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3 + * Derivative: B'(t) = 3(1-t)²(P1-P0) + 6(1-t)t(P2-P1) + 3t²(P3-P2) + */ +export const getBezierMidpointAngle = ( + sourceX: number, + sourceY: number, + targetX: number, + targetY: number, + sourcePosition: string, + targetPosition: string, +): number => { + // Calculate control points (ReactFlow's default Bezier calculation) + const curvature = 0.25; + const offsetX = Math.abs(targetX - sourceX) * curvature; + const offsetY = Math.abs(targetY - sourceY) * curvature; + + let controlPoint1X = sourceX; + let controlPoint1Y = sourceY; + let controlPoint2X = targetX; + let controlPoint2Y = targetY; + + // Adjust control points based on handle positions + if (sourcePosition === "right") controlPoint1X += offsetX; + else if (sourcePosition === "left") controlPoint1X -= offsetX; + else if (sourcePosition === "bottom") controlPoint1Y += offsetY; + else if (sourcePosition === "top") controlPoint1Y -= offsetY; + + if (targetPosition === "left") controlPoint2X -= offsetX; + else if (targetPosition === "right") controlPoint2X += offsetX; + else if (targetPosition === "top") controlPoint2Y -= offsetY; + else if (targetPosition === "bottom") controlPoint2Y += offsetY; + + // Calculate derivative at t=0.5 (midpoint) + const t = 0.5; + const oneMinusT = 1 - t; + + // B'(t) at t=0.5 + const dx = + 3 * oneMinusT * oneMinusT * (controlPoint1X - sourceX) + + 6 * oneMinusT * t * (controlPoint2X - controlPoint1X) + + 3 * t * t * (targetX - controlPoint2X); + + const dy = + 3 * oneMinusT * oneMinusT * (controlPoint1Y - sourceY) + + 6 * oneMinusT * t * (controlPoint2Y - controlPoint1Y) + + 3 * t * t * (targetY - controlPoint2Y); + + // Calculate angle from derivative + const angle = Math.atan2(dy, dx) * (180 / Math.PI); + + // Normalize to keep text readable + if (angle > 90 || angle < -90) { + return angle + 180; + } + + return angle; +};