Skip to content

Commit db36b51

Browse files
Graph visualization and end nodes
- Add graph visualization using GraphViz - Make tool to add end nodes when deem a logical end Co-authored-by: Sarmisuper <stharm20@student.aau.dk>
1 parent 51a25e8 commit db36b51

16 files changed

+318
-33
lines changed

ChatRPG/API/Tools/AddEdgeTool.cs

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public class AddEdgeTool(
3232
return addEdgeErrorMessage ?? "Failed to add edge.";
3333
}
3434

35-
return $"The graph has been updated. From now on, use the updated graph::\n{graph.Serialize()}";
35+
return $"The graph has been updated. From now on, use the updated graph:\n{graph.Serialize()}";
3636
}
3737

3838
private static bool IsValidJson(AddEdgeInput jsonEdge, out string? errorMessage)
@@ -55,27 +55,7 @@ private static bool TryAddEdge(NarrativeGraph graph, AddEdgeInput newEdge, out s
5555
existingNodes.TryGetValue(newEdge.SourceNodeName!, out var sourceNode);
5656
existingNodes.TryGetValue(newEdge.TargetNodeName!, out var targetNode);
5757

58-
var errorMessages = new List<string>();
59-
60-
if (targetNode is null)
61-
{
62-
errorMessages.Add($"Target node with name {newEdge.TargetNodeName} not found.");
63-
}
64-
65-
if (sourceNode is null)
66-
{
67-
errorMessages.Add($"Source node with name {newEdge.SourceNodeName} not found.");
68-
}
69-
else if (sourceNode == targetNode)
70-
{
71-
errorMessages.Add($"Node {sourceNode.Name} cannot have an edge to itself.");
72-
}
73-
else if (targetNode is not null && sourceNode.Edges.Any(e => e.TargetNode == targetNode))
74-
{
75-
errorMessages.Add($"An edge already exists between {newEdge.SourceNodeName} and {newEdge.TargetNodeName}.");
76-
}
77-
78-
if (!ToolUtilities.NodesValidForNewEdge(sourceNode, targetNode, newEdge, out errorMessages))
58+
if (!ToolUtilities.NodesValidForNewEdge(sourceNode, targetNode, newEdge, out var errorMessages))
7959
{
8060
errorMessage =
8161
$"Invalid input provided for the edge. " +

ChatRPG/API/Tools/AddEndNodeInput.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace ChatRPG.API.Tools;
2+
3+
public class AddEndNodeInput
4+
{
5+
public string? SourceNodeName { get; set; }
6+
7+
public List<string>? Conditions { get; set; }
8+
9+
public bool IsValid(out List<string> validationErrors)
10+
{
11+
validationErrors = [];
12+
13+
if (string.IsNullOrWhiteSpace(SourceNodeName))
14+
{
15+
validationErrors.Add("SourceNodeName is required");
16+
}
17+
18+
if (Conditions is null || Conditions.Count == 0)
19+
{
20+
validationErrors.Add("Conditions list is required.");
21+
}
22+
else
23+
{
24+
foreach (var condition in Conditions)
25+
{
26+
if (string.IsNullOrWhiteSpace(condition))
27+
{
28+
validationErrors.Add("Condition is required.");
29+
}
30+
}
31+
}
32+
33+
return validationErrors.Count == 0;
34+
}
35+
}

ChatRPG/API/Tools/AddEndNodeTool.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System.Text.Json;
2+
using ChatRPG.Data.Models;
3+
using LangChain.Chains.StackableChains.Agents.Tools;
4+
5+
namespace ChatRPG.API.Tools;
6+
7+
public class AddEndNodeTool(
8+
NarrativeGraph graph,
9+
string name,
10+
string? description = null) : AgentTool(name, description)
11+
{
12+
private static readonly JsonSerializerOptions JsonOptions = new()
13+
{
14+
PropertyNameCaseInsensitive = true
15+
};
16+
17+
public override async Task<string> ToolTask(string input, CancellationToken token = new CancellationToken())
18+
{
19+
await Task.Yield();
20+
21+
var addEndNodeInput =
22+
JsonSerializer.Deserialize<AddEndNodeInput>(ToolUtilities.RemoveMarkdown(input), JsonOptions) ??
23+
throw new JsonException("Failed to deserialize");
24+
25+
if (!IsValidJson(addEndNodeInput, out var jsonValidationError))
26+
{
27+
return jsonValidationError ?? "Invalid JSON input.";
28+
}
29+
30+
if (!TryAddEndNode(graph, addEndNodeInput, out var addEndNodeErrorMessage))
31+
{
32+
return addEndNodeErrorMessage ?? "Failed to add edge to end node.";
33+
}
34+
35+
return $"The graph has been updated. From now on, use the updated graph:\n{graph.Serialize()}";
36+
}
37+
38+
private static bool IsValidJson(AddEndNodeInput jsonNode, out string? errorMessage)
39+
{
40+
if (jsonNode.IsValid(out var errors))
41+
{
42+
errorMessage = null;
43+
return true;
44+
}
45+
46+
errorMessage =
47+
$"Invalid input provided for the edge to the end node. Please correct the following errors:\n{string.Join("\n", errors)}";
48+
return false;
49+
}
50+
51+
private static bool TryAddEndNode(NarrativeGraph graph, AddEndNodeInput newEndNode, out string? errorMessage)
52+
{
53+
var sourceNode = graph.Nodes.FirstOrDefault(n => n.Name == newEndNode.SourceNodeName);
54+
55+
if (sourceNode is null)
56+
{
57+
errorMessage = $"Source node with name {newEndNode.SourceNodeName} not found.";
58+
return false;
59+
}
60+
61+
var endNode = graph.Nodes.FirstOrDefault(n => n.Name == "End");
62+
63+
if (endNode is null)
64+
{
65+
endNode = new NarrativeNode("End", "", graph);
66+
graph.AddNode(endNode);
67+
}
68+
69+
sourceNode.Edges.Add(new NarrativeEdge(newEndNode.Conditions!, sourceNode, endNode));
70+
errorMessage = null;
71+
return true;
72+
}
73+
}

ChatRPG/ChatRPG.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,15 @@
3333
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
3434
<PackageReference Include="Radzen.Blazor" Version="6.2.4" />
3535
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
36+
<PackageReference Include="Rubjerg.Graphviz" Version="2.0.2" />
3637
</ItemGroup>
3738

3839
<ItemGroup>
3940
<InternalsVisibleTo Include="ChatRPGTests" />
4041
</ItemGroup>
4142

43+
<ItemGroup>
44+
<Folder Include="Visualization\" />
45+
</ItemGroup>
46+
4247
</Project>

ChatRPG/Data/Models/NarrativeGraph.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ public List<NarrativeNode> GetIncomingNodes(NarrativeNode targetNode)
3636
return Nodes.FirstOrDefault(node => GetIncomingNodes(node).Count == 0);
3737
}
3838

39+
public List<NarrativeNode> GetNodesWithStatus(NarrativeNode.Status status)
40+
{
41+
return Nodes.Where(n => n.NodeStatus == status).ToList();
42+
}
43+
3944
public void InitializeStartNode()
4045
{
4146
Nodes.Add(new NarrativeNode("Start", "", this));

ChatRPG/Pages/CampaignPage.razor.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using ChatRPG.Services;
33
using ChatRPG.Data.Models;
44
using ChatRPG.Services.Events;
5+
using ChatRPG.Visualization;
56
using Microsoft.AspNetCore.Components;
67
using Microsoft.AspNetCore.Components.Authorization;
78
using Microsoft.AspNetCore.Components.Web;
@@ -88,6 +89,8 @@ protected override async Task OnInitializedAsync()
8889
GameInputHandler!.ChatCompletionChunkReceived += OnChatCompletionChunkReceived;
8990
GameInputHandler!.CampaignUpdated += OnCampaignUpdated;
9091
_pageInitialized = true;
92+
93+
GraphVisualization.SaveToPngFile(_campaign!.NarrativeGraph!);
9194
}
9295

9396
/// <summary>

ChatRPG/Services/ReActScribeAgent.cs

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,23 +95,35 @@ private static List<AgentTool> CreateTools(NarrativeGraph graph)
9595
"\"targetnodename\": \"the name of the target node that should be connected using this edge. " +
9696
"This node can already exist in the graph or it can be this node, if this node is the target \" } ] } " +
9797
"Each edge must include a list of conditions (which may be empty if no prerequisites exist) and " +
98-
"connect either from or to an existing node to maintain coherence in the narrative structure.\n\n " +
98+
"connect either from or to an existing node to maintain coherence in the narrative structure. " +
99+
"These conditions must be formulated as short easy-to-answer questions.\n\n " +
99100
"Example Usage:\n " +
101+
"Example 1: Gaining Information on The Abandoned Ruins\n " +
100102
"Scenario Context:\n " +
101103
"The player is currently at \"The Village of Eldermere\". A new story point is being introduced: " +
102104
"\"The Abandoned Ruins\", which contains an ancient shrine with hidden inscriptions. The player can " +
103-
"only proceed if they have spoken to the village elder.\n " +
105+
"only proceed if they have spoken to the village elder and removed a large boulder in the way.\n " +
104106
"Tool Call Example:\n " +
105107
"{ \"name\": \"The Abandoned Ruins\", \"storycontent\": \"A crumbling stone structure overgrown " +
106108
"with vines, hiding an ancient shrine with faded inscriptions. The air is thick with mystery, and a " +
107109
"sense of forgotten history lingers. Possible discoveries include ancient artifacts and hidden passages.\", " +
108-
"\"edges\": [ { \"conditions\": [ \"Has the player spoken to the Village Elder?\" ], " +
110+
"\"edges\": [ { \"conditions\": [ \"Has the player spoken to the Village Elder?\", \"Has the player removed the large boulder?\" ], " +
109111
"\"sourcenodename\": \"The Village of Eldermere\", \"targetnodename\": \"The Abandoned Ruins\" } ] } " +
110112
"Expected Outcome:\n " +
111113
"The tool returns an updated string representation of the graph, now including \"The Abandoned Ruins\" " +
112-
"as a new node, connected to \"The Village of Eldermere\" via an edge with the condition " +
113-
"\"Has the player spoken to the Village Elder?\" You can now verify the structure and ensure " +
114-
"that traversal logic remains consistent with the scenario documents.");
114+
"as a new node, connected to \"The Village of Eldermere\" via an edge with the conditions " +
115+
"\"Has the player spoken to the Village Elder?\" and \"Has the player removed the large boulder?\" You can now verify the structure and ensure " +
116+
"that traversal logic remains consistent with the scenario documents.\n " +
117+
"Example 2: Entering the Forbidden Archives (No Conditions Required)\n " +
118+
"A new node is added when the player discovers the Forbidden Archives, an ancient library containing lost knowledge. \n" +
119+
"{ \"name\": \"Forbidden Archives\", \"storycontent\": \"A vast underground library filled with " +
120+
"crumbling tomes, forbidden knowledge, and the echoes of long-forgotten scholars. " +
121+
"Strange symbols glow faintly on the walls, hinting at secrets waiting to be uncovered.\", " +
122+
"\"edges\": [ { \"conditions\": [], \"sourcenodename\": \"Grand Library\", \"targetnodename\": \"Forbidden Archives\" } ] } " +
123+
"Outcome:\n " +
124+
"- The Forbidden Archives is introduced as a new story node.\n " +
125+
"- The Grand Library is directly connected to it without conditions, meaning the player can freely enter the archives.\n " +
126+
"- The archives can now serve as a new exploration point with potential clues, puzzles, or hidden dangers.");
115127
tools.Add(addNodeTool);
116128

117129
var addEdgeTool = new AddEdgeTool(graph, "addedgetool",
@@ -150,14 +162,58 @@ private static List<AgentTool> CreateTools(NarrativeGraph graph)
150162
"To enter the Royal Chamber, the player must have:\n " +
151163
"1. Met Sir Ivan, the Wizard, who provides the key to the chamber.\n " +
152164
"2. Defeated the Elite Guards stationed outside.\n " +
165+
"3. Dispelled the magical barrier on the Royal Chamber doors." +
153166
"Input to AddEdgeTool:\n " +
154167
"{ \"sourcenodename\": \"Castle Courtyard\", \"targetnodename\": \"Royal Chamber\", \"conditions\": " +
155-
"[ \"Has the player met Sir Ivan, the Wizard?\", \"Has the player defeated the Elite Guards?\" ] } " +
168+
"[ \"Has the player been granted the key by Sir Ivan, the Wizard?\", \"Has the player defeated the Elite Guards?\", \"Has the player dispelled the magical barrier?\" ] } " +
156169
"Outcome:\n " +
157170
"- The Castle Courtyard is now connected to the Royal Chamber.\n " +
158-
"- The player cannot enter until both conditions are fulfilled.");
171+
"- The player cannot enter until all conditions are fulfilled.");
159172
tools.Add(addEdgeTool);
160173

174+
var addEndNodeTool = new AddEndNodeTool(graph, "addendnodetool",
175+
"This tool must be used to add a new end node to the narrative graph. An end node represents " +
176+
"a definitive conclusion to a story branch, meaning that once the player reaches this point, the " +
177+
"story will end. This tool must be used whenever a branch of the story does not loop back to another " +
178+
"plot point but instead results in a final outcome.\n " +
179+
"There can be multiple possible endings in an adventure scenario, so this tool must be invoked " +
180+
"whenever a narrative path leads to a conclusion instead of continuing forward. End nodes should be " +
181+
"used to signify significant story resolutions, such as:\n " +
182+
"- The player meeting their demise.\n " +
183+
"- The player achieving victory.\n " +
184+
"- The player failing or being trapped indefinitely.\n " +
185+
"- Any other scenario where the player's journey logically concludes.\n" +
186+
"Usage Format:\n " +
187+
"The tool requires valid JSON input structured as follows:\n " +
188+
"{ \"sourcenodename\": \"the name of the source node which already exists in the graph\", " +
189+
"\"conditions\": [ \"condition that define if the ending is reached based on the player’s choices\" ] } " +
190+
"The conditions list may be empty if the edge does not require prerequisites for traversal.\n" +
191+
"Example Usage:\n " +
192+
"Example 1: A Hero’s Victory\n " +
193+
"If the player successfully defeats the Dark Lord and restores peace, the ending is triggered:\n " +
194+
"{ \"sourcenodename\": \"Victory Over the Dark Lord\", " +
195+
"\"conditions\": [ \"Has the player defeated the Dark Lord?\" ] } " +
196+
"Outcome:\n " +
197+
"- This ending is reached only if the player defeats the Dark Lord.\n " +
198+
"Example 2: The Player’s Demise\n " +
199+
"If the player fails to escape a collapsing dungeon:\n " +
200+
"{ \"sourcenodename\": \"Buried Beneath the Ruins\", " +
201+
"\"conditions\": [ \"Has the player failed to escape the ruins before time ran out?\" ] } " +
202+
"Outcome:\n " +
203+
"- The story ends when the player fails to escape the ruins.\n " +
204+
"Example 3: The Ascension of the New King\n " +
205+
"If the player successfully claims the throne by fulfilling multiple prerequisites:\n " +
206+
"{ \"sourcenodename\": \"Ascension to the Throne\", " +
207+
"\"conditions\": [ \"Has the player retrieved the Royal Crown?\", " +
208+
"\"Has the player gained the support of the High Council?\", " +
209+
"\"Has the player defeated the False Heir in battle?\" ] } " +
210+
"Outcome:\n " +
211+
"- This ending is only reached if the player has:\n " +
212+
"\t - Retrieved the Royal Crown, signifying their right to rule.\n " +
213+
"\t - Secured the High Council’s approval, ensuring political stability.\n " +
214+
"\t - Defeated the False Heir, eliminating rival claims to the throne.");
215+
tools.Add(addEndNodeTool);
216+
161217
return tools;
162218
}
163219
}

ChatRPG/Services/ScenarioDocumentService.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using LangChain.Providers.OpenAI.Predefined;
66
using LangChain.DocumentLoaders;
77
using LangChain.Extensions;
8-
using LangChain.Providers;
98
using LangChain.Splitters.Text;
109
using static LangChain.Chains.Chain;
1110

@@ -64,7 +63,7 @@ public async Task<string> GenerateStartingScenario(Campaign campaign)
6463
var prompt = new StringBuilder();
6564
prompt.Append(_startingScenarioPrompt);
6665

67-
var chain = Set("Introduce the adventure that the player will embark on", outputKey: "instruction")
66+
var chain = Set(CreateRagQuery(campaign), outputKey: "instruction")
6867
| RetrieveSimilarDocuments(vectorCollection, embeddingModel, inputKey: "instruction", amount: 20)
6968
| CombineDocuments(outputKey: "context")
7069
| Template(prompt.ToString())
@@ -74,4 +73,21 @@ public async Task<string> GenerateStartingScenario(Campaign campaign)
7473

7574
return response ?? "System: I'm sorry, I couldn't find any relevant scenarios.";
7675
}
76+
77+
private static string CreateRagQuery(Campaign campaign)
78+
{
79+
var ragQuery = new StringBuilder();
80+
ragQuery.AppendLine("Introduce the adventure that the player will embark on.");
81+
82+
var startNode = campaign.NarrativeGraph!.GetStartNode();
83+
84+
foreach (var edge in startNode!.Edges)
85+
{
86+
ragQuery.AppendLine(string.Join(", ", edge.Conditions));
87+
ragQuery.AppendLine(edge.TargetNodeName);
88+
ragQuery.AppendLine(edge.TargetNode.StoryContent);
89+
}
90+
91+
return ragQuery.ToString();
92+
}
7793
}

0 commit comments

Comments
 (0)