diff --git a/src/components/Factory/Canvas/GameCanvas.tsx b/src/components/Factory/Canvas/GameCanvas.tsx
index c28466592..f66f9c456 100644
--- a/src/components/Factory/Canvas/GameCanvas.tsx
+++ b/src/components/Factory/Canvas/GameCanvas.tsx
@@ -20,10 +20,12 @@ import { GameOverDialog } from "../components/GameOverDialog";
import { setup } from "../data/setup";
import { createBuildingNode } from "../objects/buildings/createBuildingNode";
import { setupConnections } from "../objects/resources/setupConnections";
+import { useGameActions } from "../providers/GameActionsProvider";
import { useGlobalResources } from "../providers/GlobalResourcesProvider";
import { useStatistics } from "../providers/StatisticsProvider";
import { useTime } from "../providers/TimeProvider";
import { processDay } from "../simulation/processDay";
+import { loadGameState, saveGameState } from "../utils/saveGame";
import { createIsValidConnection } from "./callbacks/isValidConnection";
import { createOnConnect } from "./callbacks/onConnect";
import { createOnDrop } from "./callbacks/onDrop";
@@ -47,13 +49,28 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => {
const [reactFlowInstance, setReactFlowInstance] =
useState();
- const { addDayStatistics, getLatestDayStats, resetStatistics, currentDay } =
- useStatistics();
- const { resources, updateResources, resetResources, setResource } =
- useGlobalResources();
+ const [hasLoadedGame, setHasLoadedGame] = useState(false);
+ const [pendingSetup, setPendingSetup] = useState(false);
+
+ const {
+ addDayStatistics,
+ getLatestDayStats,
+ resetStatistics,
+ currentDay,
+ history,
+ setStatisticsHistory,
+ } = useStatistics();
+ const {
+ resources,
+ updateResources,
+ resetResources,
+ setResource,
+ setAllResources,
+ } = useGlobalResources();
const { pause, dayAdvanceTrigger } = useTime();
+ const { registerRestartHandler } = useGameActions();
- const { fitView } = useReactFlow();
+ const { fitView, getViewport, setViewport } = useReactFlow();
const { clearContent } = useContextPanel();
const [gameOverOpen, setGameOverOpen] = useState(false);
@@ -78,9 +95,20 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => {
);
setNodes(updatedNodes);
- updateResources(statistics.global.earned);
+ const updatedResources = updateResources(statistics.global.earned);
addDayStatistics(statistics);
+ const viewport = getViewport();
+ saveGameState(
+ updatedNodes,
+ edges,
+ updatedResources,
+ [...history, statistics],
+ viewport,
+ ).catch((error) => {
+ console.error("Failed to auto-save game:", error);
+ });
+
if (statistics.global.foodDeficit > 0) {
if (!hasContinuedGame.current) {
setGameOverOpen(true);
@@ -91,11 +119,13 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => {
dayAdvanceTrigger,
currentDay,
resources,
- hasContinuedGame,
+ history,
setNodes,
updateResources,
getLatestDayStats,
addDayStatistics,
+ getViewport,
+ pause,
]);
const onInit: OnInit = (instance) => {
@@ -115,11 +145,15 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => {
};
const runSetup = () => {
+ if (!reactFlowInstance) return;
+
setNodes([]);
setEdges([]);
resetResources();
resetStatistics();
clearContent();
+ hasContinuedGame.current = false;
+ prevTriggerRef.current = 0;
const newNodes = setup.buildings?.map((building) =>
createBuildingNode(building.type, building.position),
@@ -131,33 +165,34 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => {
if (newNodes) {
setNodes(newNodes);
+ setPendingSetup(true);
}
+ };
+
+ useEffect(() => {
+ if (!pendingSetup || !reactFlowInstance || nodes.length === 0) return;
// Double RAF is needed to ensure nodes are rendered before we try to create edges between them
requestAnimationFrame(() => {
requestAnimationFrame(() => {
- if (
- setup.connections &&
- setup.buildings &&
- newNodes &&
- reactFlowInstance
- ) {
+ if (setup.connections && setup.buildings) {
const newEdges = setupConnections(
setup.connections,
setup.buildings,
- newNodes,
+ nodes,
reactFlowInstance,
);
- if (newEdges) {
- setEdges(newEdges.filter((edge): edge is Edge => edge !== null));
+ if (newEdges && newEdges.length > 0) {
+ setEdges(newEdges);
}
}
+
+ fitView({ maxZoom: 1, padding: 0.2 });
+ setPendingSetup(false);
});
});
-
- fitView({ maxZoom: 1, padding: 0.2 });
- };
+ }, [pendingSetup, reactFlowInstance, nodes, fitView]);
const handleContinuePlaying = () => {
setGameOverOpen(false);
@@ -166,13 +201,46 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => {
const handleRestart = () => {
setGameOverOpen(false);
- hasContinuedGame.current = false;
runSetup();
};
useEffect(() => {
- runSetup();
- }, [runSetup]);
+ registerRestartHandler(handleRestart);
+ }, [handleRestart, registerRestartHandler]);
+
+ useEffect(() => {
+ if (!reactFlowInstance || hasLoadedGame) return;
+
+ const loadSavedGame = async () => {
+ try {
+ const savedGame = await loadGameState();
+
+ if (savedGame) {
+ setNodes(savedGame.nodes);
+ setEdges(savedGame.edges);
+ setAllResources(savedGame.globalResources);
+ setStatisticsHistory(savedGame.statisticsHistory);
+
+ if (savedGame.viewport) {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setViewport(savedGame.viewport!, { duration: 0 });
+ });
+ });
+ }
+ } else {
+ runSetup();
+ }
+ } catch (error) {
+ console.error("Error loading saved game:", error);
+ runSetup();
+ } finally {
+ setHasLoadedGame(true);
+ }
+ };
+
+ loadSavedGame();
+ }, [reactFlowInstance, hasLoadedGame]);
return (
<>
diff --git a/src/components/Factory/Context/FactoryContext.tsx b/src/components/Factory/Context/FactoryContext.tsx
new file mode 100644
index 000000000..49e54b608
--- /dev/null
+++ b/src/components/Factory/Context/FactoryContext.tsx
@@ -0,0 +1,29 @@
+import { Button } from "@/components/ui/button";
+import { BlockStack } from "@/components/ui/layout";
+
+import { useGameActions } from "../providers/GameActionsProvider";
+
+export const FactoryContext = () => {
+ const { restartGame } = useGameActions();
+
+ return (
+
+ Factory Game
+ How long can you survive?
+
+ Every day that passes an increasing amount of food will be required for
+ survival.
+
+
+ Produce, refine and sell resources to earn money and expand your
+ production lines.
+
+ The game is autosaved at the end of every day.
+ Happy Hack Days! - The factory must grow!
+
+
+
+ );
+};
diff --git a/src/components/Factory/FactoryGameApp.tsx b/src/components/Factory/FactoryGameApp.tsx
index efe226938..1523b3827 100644
--- a/src/components/Factory/FactoryGameApp.tsx
+++ b/src/components/Factory/FactoryGameApp.tsx
@@ -9,8 +9,10 @@ import { ContextPanelProvider } from "@/providers/ContextPanelProvider";
import GameCanvas from "./Canvas/GameCanvas";
import { AnchoredToastContainer } from "./components/AnchoredToast";
+import { FactoryContext } from "./Context/FactoryContext";
import GameControls from "./Controls/GameControls";
import { AnchoredToastProvider } from "./providers/AnchoredToastProvider";
+import { GameActionsProvider } from "./providers/GameActionsProvider";
import { TimeProvider } from "./providers/TimeProvider";
import GameSidebar from "./Sidebar/GameSidebar";
@@ -33,30 +35,32 @@ const FactoryGameApp = () => {
};
return (
-
-
-
-
- Factory Game
}>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/src/components/Factory/data/buildings.ts b/src/components/Factory/data/buildings.ts
index 3ce1930b2..1877f7891 100644
--- a/src/components/Factory/data/buildings.ts
+++ b/src/components/Factory/data/buildings.ts
@@ -210,7 +210,7 @@ export const BUILDINGS: Record = {
description: "Turns wood into planks",
cost: 0,
color: "#D2691E",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Sandpaper and Saw",
@@ -229,7 +229,7 @@ export const BUILDINGS: Record = {
description: "Turns wood into paper",
cost: 0,
color: "#6A5ACD",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Pulp and Press",
@@ -273,7 +273,7 @@ export const BUILDINGS: Record = {
description: "Processes livestock",
cost: 0,
color: "#8B0000",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Carving Knives",
@@ -292,7 +292,7 @@ export const BUILDINGS: Record = {
description: "Produces books",
cost: 0,
color: "#4B0082",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Bookbinding",
@@ -311,7 +311,7 @@ export const BUILDINGS: Record = {
description: "Grind wheat into flour",
cost: 0,
color: "#DAA520",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Grinding Stones",
@@ -327,7 +327,7 @@ export const BUILDINGS: Record = {
description: "Burns wood into coal",
cost: 0,
color: "#36454F",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Charcoal Burning",
@@ -343,7 +343,7 @@ export const BUILDINGS: Record = {
description: "Bakes bread and other treats!",
cost: 0,
color: "#F5CEB3",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Oven Baking",
@@ -461,7 +461,7 @@ export const BUILDINGS: Record = {
description: "Smelts ores into metal",
cost: 0,
color: "#B22222",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Bronze",
@@ -489,7 +489,7 @@ export const BUILDINGS: Record = {
description: "Crafts tools for various purposes",
cost: 0,
color: "#8B4513",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Stone Tools",
@@ -569,7 +569,7 @@ export const BUILDINGS: Record = {
description: "Produces coins from metal",
cost: 0,
color: "#FFD700",
- category: "refining",
+ category: "manufacturing",
productionMethods: [
{
name: "Copper Coins",
diff --git a/src/components/Factory/data/setup.ts b/src/components/Factory/data/setup.ts
index 111b69e7d..557de4b45 100644
--- a/src/components/Factory/data/setup.ts
+++ b/src/components/Factory/data/setup.ts
@@ -44,7 +44,7 @@ const buildings: BuildingSetup[] = [
const resources: ResourceSetup[] = [
{ type: "money", amount: 1000 },
- { type: "food", amount: 200 },
+ { type: "food", amount: 100 },
];
const connections: ConnectionSetup[] = [
diff --git a/src/components/Factory/providers/GameActionsProvider.tsx b/src/components/Factory/providers/GameActionsProvider.tsx
new file mode 100644
index 000000000..e2b2526cf
--- /dev/null
+++ b/src/components/Factory/providers/GameActionsProvider.tsx
@@ -0,0 +1,47 @@
+import { createContext, type ReactNode, useContext, useRef } from "react";
+
+interface GameActionsContextType {
+ restartGame: () => void;
+ registerRestartHandler: (handler: () => void) => void;
+}
+
+const GameActionsContext = createContext(
+ undefined,
+);
+
+interface GameActionsProviderProps {
+ children: ReactNode;
+}
+
+export const GameActionsProvider = ({ children }: GameActionsProviderProps) => {
+ const restartHandlerRef = useRef<(() => void) | null>(null);
+
+ const registerRestartHandler = (handler: () => void) => {
+ restartHandlerRef.current = handler;
+ };
+
+ const restartGame = () => {
+ if (restartHandlerRef.current) {
+ restartHandlerRef.current();
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useGameActions = () => {
+ const context = useContext(GameActionsContext);
+ if (!context) {
+ throw new Error("useGameActions must be used within GameActionsProvider");
+ }
+ return context;
+};
diff --git a/src/components/Factory/providers/GlobalResourcesProvider.tsx b/src/components/Factory/providers/GlobalResourcesProvider.tsx
index d413d1092..3c81673e9 100644
--- a/src/components/Factory/providers/GlobalResourcesProvider.tsx
+++ b/src/components/Factory/providers/GlobalResourcesProvider.tsx
@@ -8,11 +8,12 @@ import {
interface GlobalResourcesContextType {
resources: GlobalResources;
- updateResources: (updates: Partial) => void;
+ updateResources: (updates: Partial) => GlobalResources;
setResource: (resourceType: GlobalResourceType, amount: number) => void;
addResource: (resourceType: GlobalResourceType, amount: number) => void;
getResource: (resourceType: GlobalResourceType) => number;
resetResources: () => void;
+ setAllResources: (resources: GlobalResources) => void;
}
const GlobalResourcesContext = createContext<
@@ -30,11 +31,13 @@ const INITIAL_RESOURCES: GlobalResources = Object.fromEntries(
export const GlobalResourcesProvider = ({
children,
}: GlobalResourcesProviderProps) => {
- // Initialize all global resources to 0
+ // Initialize all global resources
const [resources, setResources] =
useState(INITIAL_RESOURCES);
const updateResources = (updates: Partial) => {
+ let updatedResources: GlobalResources = INITIAL_RESOURCES;
+
setResources((prev) => {
const updated = { ...prev };
@@ -45,8 +48,11 @@ export const GlobalResourcesProvider = ({
}
});
+ updatedResources = updated;
return updated;
});
+
+ return updatedResources;
};
const setResource = (resourceType: GlobalResourceType, amount: number) => {
@@ -71,6 +77,10 @@ export const GlobalResourcesProvider = ({
setResources(INITIAL_RESOURCES);
};
+ const setAllResources = (newResources: GlobalResources) => {
+ setResources(newResources);
+ };
+
return (
{children}
diff --git a/src/components/Factory/providers/StatisticsProvider.tsx b/src/components/Factory/providers/StatisticsProvider.tsx
index 1b30bcde8..418b81ba1 100644
--- a/src/components/Factory/providers/StatisticsProvider.tsx
+++ b/src/components/Factory/providers/StatisticsProvider.tsx
@@ -17,6 +17,7 @@ interface StatisticsContextType {
includeBreakdown?: boolean,
) => EdgeStatistics[];
resetStatistics: () => void;
+ setStatisticsHistory: (history: DayStatistics[]) => void;
}
const StatisticsContext = createContext(
@@ -78,6 +79,10 @@ export const StatisticsProvider: React.FC<{ children: React.ReactNode }> = ({
setHistory([]);
};
+ const setStatisticsHistory = (newHistory: DayStatistics[]) => {
+ setHistory(newHistory);
+ };
+
return (
= ({
getLatestDayStats,
getLatestEdgeStats,
resetStatistics,
+ setStatisticsHistory,
}}
>
{children}
diff --git a/src/components/Factory/types/buildings.ts b/src/components/Factory/types/buildings.ts
index 42f3b0752..f3478be7d 100644
--- a/src/components/Factory/types/buildings.ts
+++ b/src/components/Factory/types/buildings.ts
@@ -62,7 +62,7 @@ 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: "manufacturing", label: "Manufacturing", icon: "Factory" },
{ type: "services", label: "Services", icon: "Store" },
{ type: "storage", label: "Storage", icon: "Package" },
];
diff --git a/src/components/Factory/utils/saveGame.ts b/src/components/Factory/utils/saveGame.ts
new file mode 100644
index 000000000..4867b29d2
--- /dev/null
+++ b/src/components/Factory/utils/saveGame.ts
@@ -0,0 +1,301 @@
+import type { Edge, Node } from "@xyflow/react";
+import localforage from "localforage";
+
+import type { GlobalResources } from "../data/resources";
+import type { DayStatistics } from "../types/statistics";
+
+const factoryGameStore = localforage.createInstance({
+ name: "tangle-factory",
+ storeName: "game_saves",
+ description: "Store for Factory Game save data",
+});
+
+export interface GameSaveData {
+ version: string;
+ savedAt: number;
+ day: number;
+ nodes: Node[];
+ edges: Edge[];
+ globalResources: GlobalResources;
+ statisticsHistory: DayStatistics[];
+ viewport?: {
+ x: number;
+ y: number;
+ zoom: number;
+ };
+}
+
+const SAVE_KEY = "autosave";
+const CURRENT_VERSION = "1.0.0";
+
+/**
+ * Serialize and save the current game state to IndexedDB
+ */
+export async function saveGameState(
+ nodes: Node[],
+ edges: Edge[],
+ globalResources: GlobalResources,
+ statisticsHistory: DayStatistics[],
+ viewport?: { x: number; y: number; zoom: number },
+): Promise {
+ const saveData: GameSaveData = {
+ version: CURRENT_VERSION,
+ savedAt: Date.now(),
+ day:
+ statisticsHistory.length > 0
+ ? statisticsHistory[statisticsHistory.length - 1].global.day
+ : 0,
+ nodes: serializeNodes(nodes),
+ edges: serializeEdges(edges),
+ globalResources: { ...globalResources },
+ statisticsHistory: serializeStatistics(statisticsHistory),
+ viewport,
+ };
+
+ await factoryGameStore.setItem(SAVE_KEY, saveData);
+}
+
+/**
+ * Load the game state from IndexedDB
+ */
+export async function loadGameState(): Promise {
+ try {
+ const saveData = await factoryGameStore.getItem(SAVE_KEY);
+
+ if (!saveData) {
+ return null;
+ }
+
+ // Deserialize the statistics history to restore Maps
+ const deserializedSaveData: GameSaveData = {
+ ...saveData,
+ statisticsHistory: deserializeStatistics(saveData.statisticsHistory),
+ nodes: saveData.nodes.map(deserializeNode),
+ edges: saveData.edges.map(deserializeEdge),
+ };
+
+ return deserializedSaveData;
+ } catch (error) {
+ console.error("Error loading game state:", error);
+ return null;
+ }
+}
+
+/**
+ * Check if a save exists
+ */
+export async function hasSaveData(): Promise {
+ const saveData = await factoryGameStore.getItem(SAVE_KEY);
+ return saveData !== null;
+}
+
+/**
+ * Delete the save data
+ */
+export async function deleteSaveData(): Promise {
+ await factoryGameStore.removeItem(SAVE_KEY);
+}
+
+/**
+ * Get save metadata without loading full data
+ */
+export async function getSaveMetadata(): Promise<{
+ day: number;
+ savedAt: number;
+ version: string;
+} | null> {
+ const saveData = await factoryGameStore.getItem(SAVE_KEY);
+
+ if (!saveData) return null;
+
+ return {
+ day: saveData.day,
+ savedAt: saveData.savedAt,
+ version: saveData.version,
+ };
+}
+
+/**
+ * Serialize nodes for storage (remove functions, clean data)
+ */
+function serializeNodes(nodes: Node[]): Node[] {
+ return nodes.map((node) => ({
+ ...node,
+ data: {
+ ...node.data,
+ buildingInstance: node.data.buildingInstance
+ ? serializeBuildingInstance(node.data.buildingInstance)
+ : undefined,
+ },
+ }));
+}
+
+/**
+ * Deserialize a node, restoring Map objects in building instances
+ */
+function deserializeNode(node: Node): Node {
+ return {
+ ...node,
+ data: {
+ ...node.data,
+ buildingInstance: node.data.buildingInstance
+ ? deserializeBuildingInstance(node.data.buildingInstance)
+ : undefined,
+ },
+ };
+}
+
+/**
+ * Serialize building instance, converting Maps to objects with markers
+ */
+function serializeBuildingInstance(instance: any): any {
+ return {
+ ...instance,
+ stockpile: instance.stockpile?.map((stock: any) => ({
+ ...stock,
+ breakdown: stock.breakdown ? serializeValue(stock.breakdown) : undefined,
+ })),
+ };
+}
+
+/**
+ * Deserialize building instance, restoring Map in stockpile breakdown
+ */
+function deserializeBuildingInstance(instance: any): any {
+ return {
+ ...instance,
+ stockpile: instance.stockpile?.map((stock: any) => {
+ const deserialized: any = {
+ ...stock,
+ };
+
+ if (stock.breakdown) {
+ deserialized.breakdown = deserializeValue(stock.breakdown);
+ }
+
+ return deserialized;
+ }),
+ };
+}
+
+/**
+ * Serialize edges for storage
+ */
+function serializeEdges(edges: Edge[]): Edge[] {
+ return edges.map((edge) => ({
+ ...edge,
+ data: {
+ ...edge.data,
+ },
+ }));
+}
+
+/**
+ * Deserialize an edge, restoring any complex data structures
+ */
+function deserializeEdge(edge: Edge): Edge {
+ return {
+ ...edge,
+ data: edge.data ? deserializeValue(edge.data) : undefined,
+ };
+}
+
+/**
+ * Serialize statistics history
+ * Note: Maps need to be converted to objects for JSON serialization
+ */
+function serializeStatistics(history: DayStatistics[]): DayStatistics[] {
+ return history.map((dayStat) => ({
+ ...dayStat,
+ buildings: mapToObject(dayStat.buildings),
+ edges: mapToObject(dayStat.edges),
+ })) as any;
+}
+
+/**
+ * Deserialize statistics history
+ * Convert objects back to Maps
+ */
+export function deserializeStatistics(serialized: any[]): DayStatistics[] {
+ return serialized.map((dayStat) => ({
+ ...dayStat,
+ buildings: objectToMap(dayStat.buildings),
+ edges: objectToMap(dayStat.edges),
+ }));
+}
+
+/**
+ * Convert Map to plain object for JSON serialization
+ */
+function mapToObject(map: Map): Record {
+ const obj: Record = {};
+
+ map.forEach((value, key) => {
+ obj[key] = serializeValue(value);
+ });
+
+ return obj;
+}
+
+/**
+ * Convert plain object back to Map
+ */
+function objectToMap(obj: Record): Map {
+ const map = new Map();
+
+ Object.entries(obj).forEach(([key, value]) => {
+ map.set(key, deserializeValue(value) as V);
+ });
+
+ return map;
+}
+
+/**
+ * Recursively serialize values that might contain Maps
+ */
+function serializeValue(value: any): any {
+ if (value instanceof Map) {
+ return {
+ __isMap: true,
+ data: mapToObject(value),
+ };
+ }
+
+ if (Array.isArray(value)) {
+ return value.map(serializeValue);
+ }
+
+ if (value && typeof value === "object") {
+ const serialized: any = {};
+ Object.entries(value).forEach(([k, v]) => {
+ serialized[k] = serializeValue(v);
+ });
+ return serialized;
+ }
+
+ return value;
+}
+
+/**
+ * Recursively deserialize values that might have been Maps
+ */
+function deserializeValue(value: any): any {
+ if (value && typeof value === "object" && value.__isMap) {
+ const deserializedData = objectToMap(value.data);
+ return deserializedData;
+ }
+
+ if (Array.isArray(value)) {
+ return value.map(deserializeValue);
+ }
+
+ if (value && typeof value === "object") {
+ const deserialized: any = {};
+ Object.entries(value).forEach(([k, v]) => {
+ deserialized[k] = deserializeValue(v);
+ });
+ return deserialized;
+ }
+
+ return value;
+}