diff --git a/Scripts/Core/SaveGameData.cs b/Scripts/Core/SaveGameData.cs index ccf2106..05ddd0f 100644 --- a/Scripts/Core/SaveGameData.cs +++ b/Scripts/Core/SaveGameData.cs @@ -96,4 +96,28 @@ public class RobotSaveData public float Maintenance { get; set; } public bool IsCoolingDown { get; set; } public bool IsBroken { get; set; } + public List RunningProgramNodes { get; set; } + public string ProgramStartNode { get; set; } + public string CurrentNode { get; set; } + public bool IsExecuting { get; set; } +} + +public class NodeSaveData +{ + public string NodeType { get; set; } + public string EditorNodeId { get; set; } + public string NextEditorNodeId { get; set; } + public string NegativeEditorNodeId { get; set; } + public string NodeContent { get; set; } + public string LastExecutionMessage { get; set; } + public string ItemId { get; set; } + public string Comparator { get; set; } + public int Amount { get; set; } + public int AmountExecuted { get; set; } + public double ElapsedCraftTime { get; set; } + public int AmountCrafted { get; set; } + public bool HasTargetPosition { get; set; } + public int TargetX { get; set; } + public int TargetY { get; set; } + public int TargetZ { get; set; } } diff --git a/Scripts/DSL/Nodes/CraftNode.cs b/Scripts/DSL/Nodes/CraftNode.cs index 411f8d9..f39867d 100644 --- a/Scripts/DSL/Nodes/CraftNode.cs +++ b/Scripts/DSL/Nodes/CraftNode.cs @@ -55,4 +55,32 @@ public class CraftNode : ProgramNode { return $"Name: {DisplayText}, Item: {(selectedItem == null ? "Empty" : selectedItem.data.Id)}, Amount: {amount}"; } + + public override NodeSaveData CreateSaveData() + { + NodeSaveData saveData = base.CreateSaveData(); + saveData.ItemId = selectedItem?.data.Id; + saveData.Amount = amount; + if (selectedItem != null) + { + saveData.ElapsedCraftTime = selectedItem.elapsedCraftTime; + saveData.AmountCrafted = selectedItem.amountCrafted; + } + return saveData; + } + + public override void Load(NodeSaveData saveData) + { + base.Load(saveData); + amount = saveData.Amount; + if (string.IsNullOrEmpty(saveData.ItemId)) return; + if (!GameData.availableItems.ContainsKey(saveData.ItemId)) return; + + selectedItem = new Item + { + data = GameData.availableItems[saveData.ItemId], + elapsedCraftTime = saveData.ElapsedCraftTime, + amountCrafted = saveData.AmountCrafted + }; + } } diff --git a/Scripts/DSL/Nodes/ExploreNode.cs b/Scripts/DSL/Nodes/ExploreNode.cs index 74791ab..200f9a3 100644 --- a/Scripts/DSL/Nodes/ExploreNode.cs +++ b/Scripts/DSL/Nodes/ExploreNode.cs @@ -7,6 +7,7 @@ public class ExploreNode : ProgramNode public Vector3 startPosition; public Vector3I targetPosition; public List pathPoints; + public bool hasTargetPosition; public ExploreNode() { @@ -16,7 +17,7 @@ public class ExploreNode : ProgramNode public override NodeResult Execute(Robot robot, double delta) { - if (pathPoints == null && !TrySelectTarget()) + if (pathPoints == null && !hasTargetPosition && !TrySelectTarget()) { lastExecutionMessage = "No tiles left to explore"; return NodeResult.SUCCESS; @@ -52,6 +53,7 @@ public class ExploreNode : ProgramNode ); if (!GameData.map[targetPosition.Y].tiles[targetPosition.X, targetPosition.Z].wasVisited) { + hasTargetPosition = true; return true; } @@ -94,6 +96,7 @@ public class ExploreNode : ProgramNode lastExecutionMessage = "Current exploration finished"; pathPoints = null; + hasTargetPosition = false; return NodeResult.RUNNING; } @@ -119,7 +122,8 @@ public class ExploreNode : ProgramNode { return new ExploreNode { - targetPosition = targetPosition + targetPosition = targetPosition, + hasTargetPosition = hasTargetPosition }; } @@ -127,4 +131,27 @@ public class ExploreNode : ProgramNode { return $"Name: {DisplayText}"; } + + public override NodeSaveData CreateSaveData() + { + NodeSaveData saveData = base.CreateSaveData(); + saveData.TargetX = targetPosition.X; + saveData.TargetY = targetPosition.Y; + saveData.TargetZ = targetPosition.Z; + saveData.HasTargetPosition = hasTargetPosition || pathPoints != null; + return saveData; + } + + public override void Load(NodeSaveData saveData) + { + base.Load(saveData); + hasTargetPosition = saveData.HasTargetPosition; + if (!hasTargetPosition) return; + + targetPosition = new Vector3I( + saveData.TargetX, + saveData.TargetY, + saveData.TargetZ + ); + } } diff --git a/Scripts/DSL/Nodes/ForNode.cs b/Scripts/DSL/Nodes/ForNode.cs index 8c9d982..b16324a 100644 --- a/Scripts/DSL/Nodes/ForNode.cs +++ b/Scripts/DSL/Nodes/ForNode.cs @@ -42,6 +42,21 @@ public class ForNode : ProgramNode return $"Name: {DisplayText}, AmountExecuted: {amountExecuted}, Amount: {amount}"; } + public override NodeSaveData CreateSaveData() + { + NodeSaveData saveData = base.CreateSaveData(); + saveData.Amount = amount; + saveData.AmountExecuted = amountExecuted; + return saveData; + } + + public override void Load(NodeSaveData saveData) + { + base.Load(saveData); + amount = saveData.Amount; + amountExecuted = saveData.AmountExecuted; + } + public override void SetNextNode( List connections, Dictionary availableNodes diff --git a/Scripts/DSL/Nodes/IfNode.cs b/Scripts/DSL/Nodes/IfNode.cs index e21bdbf..184ec25 100644 --- a/Scripts/DSL/Nodes/IfNode.cs +++ b/Scripts/DSL/Nodes/IfNode.cs @@ -60,6 +60,26 @@ public class IfNode : ProgramNode return $"Name: {DisplayText}, Item: {(selectedItem == null ? "Empty" : selectedItem.data.Id)}, Comparator: {comparator}, Amount: {amount}"; } + public override NodeSaveData CreateSaveData() + { + NodeSaveData saveData = base.CreateSaveData(); + saveData.ItemId = selectedItem?.data.Id; + saveData.Comparator = comparator; + saveData.Amount = amount; + return saveData; + } + + public override void Load(NodeSaveData saveData) + { + base.Load(saveData); + amount = saveData.Amount; + comparator = saveData.Comparator; + if (string.IsNullOrEmpty(saveData.ItemId)) return; + if (!GameData.availableItems.ContainsKey(saveData.ItemId)) return; + + selectedItem = new Item { data = GameData.availableItems[saveData.ItemId] }; + } + public override void SetNextNode( List connections, Dictionary availableNodes diff --git a/Scripts/DSL/Nodes/MoveNode.cs b/Scripts/DSL/Nodes/MoveNode.cs index 9e7b7e9..dd1530a 100644 --- a/Scripts/DSL/Nodes/MoveNode.cs +++ b/Scripts/DSL/Nodes/MoveNode.cs @@ -98,4 +98,26 @@ public class MoveNode : ProgramNode { return $"Name: {DisplayText}, Position: ({targetPosition.X}|{targetPosition.Y}|{targetPosition.Z})"; } + + public override NodeSaveData CreateSaveData() + { + NodeSaveData saveData = base.CreateSaveData(); + saveData.TargetX = targetPosition.X; + saveData.TargetY = targetPosition.Y; + saveData.TargetZ = targetPosition.Z; + saveData.HasTargetPosition = true; + return saveData; + } + + public override void Load(NodeSaveData saveData) + { + base.Load(saveData); + if (!saveData.HasTargetPosition) return; + + targetPosition = new Vector3I( + saveData.TargetX, + saveData.TargetY, + saveData.TargetZ + ); + } } diff --git a/Scripts/DSL/Nodes/ProgramNode.cs b/Scripts/DSL/Nodes/ProgramNode.cs index 42da444..9b8b9ea 100644 --- a/Scripts/DSL/Nodes/ProgramNode.cs +++ b/Scripts/DSL/Nodes/ProgramNode.cs @@ -14,6 +14,52 @@ public abstract class ProgramNode public abstract ProgramNode Duplicate(); public abstract string Save(); + public virtual NodeSaveData CreateSaveData() + { + return new NodeSaveData + { + NodeType = DisplayText, + EditorNodeId = EditorNodeId, + NextEditorNodeId = nextNode?.EditorNodeId, + NegativeEditorNodeId = NegativeNode?.EditorNodeId, + NodeContent = Save(), + LastExecutionMessage = lastExecutionMessage + }; + } + + public virtual void Load(NodeSaveData saveData) + { + EditorNodeId = saveData.EditorNodeId; + lastExecutionMessage = saveData.LastExecutionMessage; + } + + public void RestoreConnections( + NodeSaveData saveData, + Dictionary availableNodes + ) + { + nextNode = GetSavedConnection(saveData.NextEditorNodeId, availableNodes); + NegativeNode = GetSavedConnection(saveData.NegativeEditorNodeId, availableNodes); + } + + public static ProgramNode CreateFromSaveData(NodeSaveData saveData) + { + return saveData.NodeType?.ToLower() switch + { + "start" => new StartNode(), + "move" => new MoveNode(), + "explore" => new ExploreNode(), + "harvest" => new HarvestNode(), + "craft" => new CraftNode(), + "if" => new IfNode(), + "while" => new WhileNode(), + "for" => new ForNode(), + "maintain" => new MaintainNode(), + "sacrifice" => new SacrificeNode(), + _ => null + }; + } + public ProgramNode DuplicateForRuntime(string editorNodeId) { ProgramNode duplicate = Duplicate(); @@ -65,4 +111,15 @@ public abstract class ProgramNode return availableNodes[nodeName]; } + + private ProgramNode GetSavedConnection( + string nodeId, + Dictionary availableNodes + ) + { + if (string.IsNullOrEmpty(nodeId)) return null; + if (!availableNodes.ContainsKey(nodeId)) return null; + + return availableNodes[nodeId]; + } } diff --git a/Scripts/DSL/Nodes/WhileNode.cs b/Scripts/DSL/Nodes/WhileNode.cs index f35cd62..1d222d4 100644 --- a/Scripts/DSL/Nodes/WhileNode.cs +++ b/Scripts/DSL/Nodes/WhileNode.cs @@ -60,6 +60,26 @@ public class WhileNode : ProgramNode return $"Name: {DisplayText}, Item: {(selectedItem == null ? "Empty" : selectedItem.data.Id)}, Comparator: {comparator}, Amount: {amount}"; } + public override NodeSaveData CreateSaveData() + { + NodeSaveData saveData = base.CreateSaveData(); + saveData.ItemId = selectedItem?.data.Id; + saveData.Comparator = comparator; + saveData.Amount = amount; + return saveData; + } + + public override void Load(NodeSaveData saveData) + { + base.Load(saveData); + amount = saveData.Amount; + comparator = saveData.Comparator; + if (string.IsNullOrEmpty(saveData.ItemId)) return; + if (!GameData.availableItems.ContainsKey(saveData.ItemId)) return; + + selectedItem = new Item { data = GameData.availableItems[saveData.ItemId] }; + } + public override void SetNextNode( List connections, Dictionary availableNodes diff --git a/Scripts/Gameplay/Robots/Robot.cs b/Scripts/Gameplay/Robots/Robot.cs index 21f9fca..0943793 100644 --- a/Scripts/Gameplay/Robots/Robot.cs +++ b/Scripts/Gameplay/Robots/Robot.cs @@ -23,6 +23,7 @@ public partial class Robot : Node3D public bool isBroken = false; public string robotType = "iron_robot"; public bool showOnMap = true; + public List nodes = new List(); private RobotTypeStats TypeStats => GameData.robotStats.RobotTypes.TryGetValue(robotType, out RobotTypeStats stats) @@ -107,6 +108,7 @@ public partial class Robot : Node3D programStartNode = nodes[0]; currentNode = nodes[0]; currentMessage = ""; + this.nodes = nodes; } public void StopExecution(string message) @@ -173,7 +175,11 @@ public partial class Robot : Node3D Heat = heat, Maintenance = maintenance, IsCoolingDown = isCoolingDown, - IsBroken = isBroken + IsBroken = isBroken, + RunningProgramNodes = CreateProgramSaveData(), + ProgramStartNode = programStartNode?.EditorNodeId, + CurrentNode = currentNode?.EditorNodeId, + IsExecuting = isExecuting }; } @@ -188,6 +194,7 @@ public partial class Robot : Node3D maintenance = saveData.Maintenance; isCoolingDown = saveData.IsCoolingDown; isBroken = saveData.IsBroken; + LoadProgramState(saveData); } private bool CanExecute(double delta) @@ -283,4 +290,78 @@ public partial class Robot : Node3D isCoolingDown = false; } } + + private List CreateProgramSaveData() + { + List result = new List(); + if (nodes == null) return result; + + foreach (ProgramNode node in nodes) + { + if (node == null) continue; + result.Add(node.CreateSaveData()); + } + return result; + } + + private void LoadProgramState(RobotSaveData saveData) + { + nodes = new List(); + programStartNode = null; + currentNode = null; + isExecuting = false; + + if (saveData.RunningProgramNodes == null || saveData.RunningProgramNodes.Count <= 0) + { + return; + } + + Dictionary loadedNodes = new Dictionary(); + foreach (NodeSaveData nodeSaveData in saveData.RunningProgramNodes) + { + ProgramNode node = ProgramNode.CreateFromSaveData(nodeSaveData); + if (node == null) continue; + + node.Load(nodeSaveData); + nodes.Add(node); + + if (!string.IsNullOrEmpty(node.EditorNodeId) && !loadedNodes.ContainsKey(node.EditorNodeId)) + { + loadedNodes.Add(node.EditorNodeId, node); + } + } + + foreach (NodeSaveData nodeSaveData in saveData.RunningProgramNodes) + { + if (string.IsNullOrEmpty(nodeSaveData.EditorNodeId)) continue; + if (!loadedNodes.ContainsKey(nodeSaveData.EditorNodeId)) continue; + + loadedNodes[nodeSaveData.EditorNodeId].RestoreConnections(nodeSaveData, loadedNodes); + } + + programStartNode = GetLoadedNode(saveData.ProgramStartNode, loadedNodes); + if (programStartNode == null && nodes.Count > 0) + { + programStartNode = nodes[0]; + } + + currentNode = GetLoadedNode(saveData.CurrentNode, loadedNodes); + if (currentNode == null) + { + currentNode = programStartNode; + } + + isExecuting = saveData.IsExecuting && currentNode != null; + } + + private ProgramNode GetLoadedNode( + string nodeId, + Dictionary loadedNodes + ) + { + if (string.IsNullOrEmpty(nodeId)) return null; + if (!loadedNodes.ContainsKey(nodeId)) return null; + + return loadedNodes[nodeId]; + } } diff --git a/Scripts/Tests/TestRunner.cs b/Scripts/Tests/TestRunner.cs index 70f8ef9..896f36c 100644 --- a/Scripts/Tests/TestRunner.cs +++ b/Scripts/Tests/TestRunner.cs @@ -23,6 +23,7 @@ public partial class TestRunner : Node Run("Resource extraction and save data roundtrip", TestResourceSaveRoundtrip); Run("Endless resources extract slower", TestEndlessResourcesExtractSlower); Run("Robot save data roundtrip keeps robot state", TestRobotSaveRoundtrip); + Run("Robot save data roundtrip keeps running program", TestRobotProgramSaveRoundtrip); Run("Maintain node consumes matching gear", TestMaintainNodeConsumesMatchingGear); Run("Sacrifice node makes resource endless", TestSacrificeNodeMakesResourceEndless); Run("No robot recovery detects loss", TestNoRobotRecoveryDetectsLoss); @@ -229,6 +230,66 @@ public partial class TestRunner : Node AssertTrue(loadedRobot.isCoolingDown, "robot cooling state"); } + private void TestRobotProgramSaveRoundtrip() + { + StartNode startNode = new StartNode { EditorNodeId = "start" }; + ForNode forNode = new ForNode + { + EditorNodeId = "loop", + amount = 3, + amountExecuted = 2 + }; + CraftNode craftNode = new CraftNode + { + EditorNodeId = "craft", + selectedItem = new Item + { + data = GameData.availableItems["stone"], + elapsedCraftTime = 0.5, + amountCrafted = 1 + }, + amount = 2 + }; + HarvestNode harvestNode = new HarvestNode { EditorNodeId = "done" }; + + startNode.nextNode = forNode; + forNode.nextNode = craftNode; + forNode.NegativeNode = harvestNode; + craftNode.nextNode = forNode; + + Robot robot = new Robot + { + Name = "Loop Bot", + currentProgram = "Looping", + isExecuting = true, + programStartNode = startNode, + currentNode = craftNode, + nodes = new List { startNode, forNode, craftNode, harvestNode } + }; + + RobotSaveData saveData = robot.CreateSaveData(); + Robot loadedRobot = new Robot(); + loadedRobot.LoadSaveData(saveData); + + AssertTrue(loadedRobot.isExecuting, "loaded robot should still execute"); + AssertEqual("craft", loadedRobot.currentNode.EditorNodeId, "current node id"); + AssertEqual("start", loadedRobot.programStartNode.EditorNodeId, "start node id"); + AssertEqual(4, loadedRobot.nodes.Count, "loaded node count"); + + ForNode loadedForNode = loadedRobot.nodes[1] as ForNode; + CraftNode loadedCraftNode = loadedRobot.nodes[2] as CraftNode; + + AssertEqual(3, loadedForNode.amount, "for amount"); + AssertEqual(2, loadedForNode.amountExecuted, "for amount executed"); + AssertTrue(ReferenceEquals(loadedCraftNode, loadedForNode.nextNode), "for success branch"); + AssertTrue(ReferenceEquals(loadedRobot.nodes[3], loadedForNode.NegativeNode), "for negative branch"); + AssertTrue(ReferenceEquals(loadedForNode, loadedCraftNode.nextNode), "craft loops back"); + AssertEqual("stone", loadedCraftNode.selectedItem.data.Id, "craft item"); + AssertEqual(2, loadedCraftNode.amount, "craft amount"); + AssertClose(0.5f, (float)loadedCraftNode.selectedItem.elapsedCraftTime, 0.001f, "craft elapsed"); + AssertEqual(1, loadedCraftNode.selectedItem.amountCrafted, "craft crafted amount"); + } + private void TestMaintainNodeConsumesMatchingGear() { Robot robot = new Robot @@ -475,9 +536,9 @@ public partial class TestRunner : Node amount = 2 }; - AssertEqual(NodeResult.CONDITIONFALSE, node.Execute(null, 0), "first iteration"); - AssertEqual(NodeResult.CONDITIONFALSE, node.Execute(null, 0), "second iteration"); - AssertEqual(NodeResult.SUCCESS, node.Execute(null, 0), "loop finished"); + AssertEqual(NodeResult.SUCCESS, node.Execute(null, 0), "first iteration"); + AssertEqual(NodeResult.SUCCESS, node.Execute(null, 0), "second iteration"); + AssertEqual(NodeResult.CONDITIONFALSE, node.Execute(null, 0), "loop finished"); } private void TestRuntimeNodeKeepsEditorIdWithoutSharingState()