diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index 22ea617..034978f 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -79,7 +79,7 @@ public ReActAgentChain( string? gameSummary = null, string inputKey = "input", string outputKey = "text", - int maxActions = 10) + int maxActions = 20) { _model = model; _model.Settings!.StopSequences = ["Observation", "[END]"]; @@ -116,7 +116,7 @@ public ReActAgentChain( string? gameSummary = null, string inputKey = "input", string outputKey = "text", - int maxActions = 10) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) + int maxActions = 20) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) { _actionPrompt = actionPrompt ?? string.Empty; } @@ -130,7 +130,7 @@ public ReActAgentChain( string? gameSummary = null, string inputKey = "input", string outputKey = "text", - int maxActions = 10) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) + int maxActions = 20) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) { _characters = characters; _playerCharacter = playerCharacter; @@ -215,6 +215,7 @@ await _conversationBufferMemory.ChatHistory.AddMessage(new Message("Thought:", M } } - throw new ReActChainNoFinalAnswerReachedException("The ReAct Chain could not reach a final answer", values); + throw new ReActChainNoFinalAnswerReachedException($"The ReAct Chain could not reach a final answer. " + + $"Values: {values}", values); } } diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 125f720..7d34c9b 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -1,6 +1,7 @@ using ChatRPG.API.Tools; using ChatRPG.Data.Models; using LangChain.Chains.StackableChains.Agents.Tools; +using LangChain.Providers; using LangChain.Providers.OpenAI; using LangChain.Providers.OpenAI.Predefined; using static LangChain.Chains.Chain; @@ -12,6 +13,7 @@ public class ReActLlmClient : IReActLlmClient private readonly IConfiguration _configuration; private readonly OpenAiProvider _provider; private readonly string _reActPrompt; + private readonly bool _narratorDebugMode; public ReActLlmClient(IConfiguration configuration) { @@ -20,15 +22,18 @@ public ReActLlmClient(IConfiguration configuration) _configuration = configuration; _reActPrompt = _configuration.GetSection("SystemPrompts").GetValue("ReAct")!; _provider = new OpenAiProvider(_configuration.GetSection("ApiKeys").GetValue("OpenAI")!); + _narratorDebugMode = _configuration.GetValue("NarrativeChainDebug")!; } public async Task GetChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { - var llm = new Gpt4Model(_provider) + var llm = new Gpt4OmniModel(_provider) { Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } }; - var agent = new ReActAgentChain(llm, _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary); + + var agent = new ReActAgentChain(_narratorDebugMode ? llm.UseConsoleForDebug() : llm, _reActPrompt, + actionPrompt: actionPrompt, campaign.GameSummary); var tools = CreateTools(campaign); foreach (var tool in tools) { @@ -42,13 +47,14 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { - var agentLlm = new Gpt4Model(_provider) + var llm = new Gpt4OmniModel(_provider) { Settings = new OpenAiChatSettings() { UseStreaming = true, Temperature = 0.7 } }; - var eventProcessor = new LlmEventProcessor(agentLlm); - var agent = new ReActAgentChain(agentLlm, _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary); + var eventProcessor = new LlmEventProcessor(llm); + var agent = new ReActAgentChain(_narratorDebugMode ? llm.UseConsoleForDebug() : llm, _reActPrompt, + actionPrompt: actionPrompt, campaign.GameSummary); var tools = CreateTools(campaign); foreach (var tool in tools) { @@ -81,7 +87,8 @@ private List CreateTools(Campaign campaign) "Input to this tool must be in the following RAW JSON format: {\"input\": \"The player's input\", " + "\"severity\": \"Describes how devastating the injury to the character will be based on the action. " + "Can be one of the following values: {low, medium, high, extraordinary}}\". Do not use markdown, " + - "only raw JSON as input. Use this tool only once per character at most."); + "only raw JSON as input. Use this tool only once per character at most and only if they are not engaged " + + "in battle."); tools.Add(woundCharacterTool); var healCharacterTool = new HealCharacterTool(_configuration, campaign, utils, "healcharactertool", @@ -97,9 +104,39 @@ private List CreateTools(Campaign campaign) "Do not use markdown, only raw JSON as input. Use this tool only once per character at most."); tools.Add(healCharacterTool); - // Use battle when an attack can be mitigated or dodged by the involved participants. - // This tool is appropriate for combat, battle between multiple participants, - // or attacks that can be avoided and a to-hit roll would be needed in order to determine a hit. + var battleTool = new BattleTool(_configuration, campaign, utils, "battletool", + "Use the battle tool to resolve battle or combat between two participants. A participant is " + + "a single character and cannot be a combination of characters. If there are more " + + "than two participants, the tool must be used once per attacker to give everyone a chance at fighting. " + + "The battle tool will give each participant a chance to fight the other participant. The tool should " + + "also be used when an attack can be mitigated or dodged by the involved participants. It is also " + + "possible for either or both participants to miss. A hit chance specifier will help adjust the chance " + + "that a participant gets to retaliate. Example: There are only two combatants. Call the tool only ONCE " + + "since both characters get an attack. Another example: There are three combatants, the Player's character " + + "and two assassins. The battle tool is called first with the Player's character as participant one and " + + "one of the assassins as participant two. Chances are high that the player will hit the assassin but " + + "assassins must be precise, making it harder to hit, however, they deal high damage if they hit. We " + + "observe that the participant one hits participant two and participant two misses participant one. " + + "After this round of battle has been resolved, call the tool again with the Player's character as " + + "participant one and the other assassin as participant two. Since participant one in this case has " + + "already hit once during this narrative, we impose a penalty to their hit chance, which is " + + "accumulative for each time they hit an enemy during battle. The damage severity describes how " + + "powerful the attack is which is derived from the narrative description of the attacks. " + + "If the participants engage in a friendly sparring fight, does not intend to hurt, or does mock battle, " + + "the damage severity is . " + + "If there are no direct description, estimate the impact of an attack based on the character type and " + + "their description. Input to this tool must be in the following RAW JSON format: {\"participant1\": " + + "{\"name\": \"\", \"description\": \"\"}, " + + "\"participant2\": {\"name\": \"\", \"description\": " + + "\"\"}, \"participant1HitChance\": \"\", \"participant2HitChance\": \"\", " + + "\"participant1DamageSeverity\": \"\", " + + "\"participant2DamageSeverity\": \"\"} where participant#HitChance " + + "specifiers are one of the following {high, medium, low, impossible} and participant#DamageSeverity is " + + "one of the following {harmless, low, medium, high, extraordinary}. Do not use markdown, only raw JSON as " + + "input. The narrative battle is over when each character has had the chance to attack another character at " + + "most once."); + tools.Add(battleTool); return tools; } diff --git a/ChatRPG/API/Tools/BattleInput.cs b/ChatRPG/API/Tools/BattleInput.cs new file mode 100644 index 0000000..6d62a4a --- /dev/null +++ b/ChatRPG/API/Tools/BattleInput.cs @@ -0,0 +1,58 @@ +namespace ChatRPG.API.Tools; + +public class BattleInput +{ + private static readonly HashSet ValidChancesToHit = + ["high", "medium", "low", "impossible"]; + + private static readonly HashSet ValidDamageSeverities = + ["harmless", "low", "medium", "high", "extraordinary"]; + + public CharacterInput? Participant1 { get; set; } + public CharacterInput? Participant2 { get; set; } + public string? Participant1HitChance { get; set; } + public string? Participant2HitChance { get; set; } + public string? Participant1DamageSeverity { get; set; } + public string? Participant2DamageSeverity { get; set; } + + public bool IsValid(out List validationErrors) + { + validationErrors = []; + + if (Participant1 == null) + { + validationErrors.Add("Participant1 is required."); + } + else if (!Participant1.IsValidForBattle(out var participant1Errors)) + { + validationErrors.AddRange(participant1Errors.Select(e => $"Participant1: {e}")); + } + + if (Participant2 == null) + { + validationErrors.Add("Participant2 is required."); + } + else if (!Participant2.IsValidForBattle(out var participant2Errors)) + { + validationErrors.AddRange(participant2Errors.Select(e => $"Participant2: {e}")); + } + + if (Participant1HitChance != null && !ValidChancesToHit.Contains(Participant1HitChance)) + validationErrors.Add( + "Participant1ChanceToHit must be one of the following: high, medium, low, impossible."); + + if (Participant2HitChance != null && !ValidChancesToHit.Contains(Participant2HitChance)) + validationErrors.Add( + "Participant2ChanceToHit must be one of the following: high, medium, low, impossible."); + + if (Participant1DamageSeverity != null && !ValidDamageSeverities.Contains(Participant1DamageSeverity)) + validationErrors.Add( + "Participant1DamageSeverity must be one of the following: harmless, low, medium, high, extraordinary."); + + if (Participant2DamageSeverity != null && !ValidDamageSeverities.Contains(Participant2DamageSeverity)) + validationErrors.Add( + "Participant2DamageSeverity must be one of the following: harmless, low, medium, high, extraordinary."); + + return validationErrors.Count == 0; + } +} diff --git a/ChatRPG/API/Tools/BattleTool.cs b/ChatRPG/API/Tools/BattleTool.cs new file mode 100644 index 0000000..f643c8d --- /dev/null +++ b/ChatRPG/API/Tools/BattleTool.cs @@ -0,0 +1,174 @@ +using System.Text; +using System.Text.Json; +using ChatRPG.Data.Models; +using LangChain.Chains.StackableChains.Agents.Tools; + +namespace ChatRPG.API.Tools; + +public class BattleTool( + IConfiguration configuration, + Campaign campaign, + ToolUtilities utilities, + string name, + string? description = null) : AgentTool(name, description) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly Dictionary HitChance = new() + { + { "high", 0.9 }, + { "medium", 0.5 }, + { "low", 0.3 }, + { "impossible", 0.01 } + }; + + private static readonly Dictionary DamageRanges = new() + { + { "harmless", (0, 1) }, + { "low", (5, 10) }, + { "medium", (10, 20) }, + { "high", (15, 25) }, + { "extraordinary", (25, 80) } + }; + + public override async Task ToolTask(string input, CancellationToken token = new CancellationToken()) + { + try + { + var battleInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? + throw new JsonException("Failed to deserialize"); + var instruction = configuration.GetSection("SystemPrompts").GetValue("BattleInstruction")!; + + if (!battleInput.IsValid(out var errors)) + { + var errorMessage = new StringBuilder(); + errorMessage.Append( + "Invalid input provided for the battle. Please provide valid input and correct the following errors:\n"); + foreach (var validationError in errors) + { + errorMessage.Append(validationError + "\n"); + } + + return errorMessage.ToString(); + } + + var participant1 = await utilities.FindCharacter(campaign, + $"{{\"name\": \"{battleInput.Participant1!.Name}\", " + + $"\"description\": \"{battleInput.Participant1.Description}\"}}", instruction); + var participant2 = await utilities.FindCharacter(campaign, + $"{{\"name\": \"{battleInput.Participant2!.Name}\", " + + $"\"description\": \"{battleInput.Participant2.Description}\"}}", instruction); + + // Create dummy characters if they do not exist and pray that the archive chain will update them + participant1 ??= new Character(campaign, campaign.Player.Environment, CharacterType.Humanoid, + battleInput.Participant1.Name!, battleInput.Participant1.Description!, false); + participant2 ??= new Character(campaign, campaign.Player.Environment, CharacterType.Humanoid, + battleInput.Participant2.Name!, battleInput.Participant2.Description!, false); + + var firstHitter = DetermineFirstHitter(participant1, participant2); + + Character secondHitter; + string firstHitChance; + string secondHitChance; + string firstHitSeverity; + string secondHitSeverity; + + if (firstHitter == participant1) + { + secondHitter = participant2; + firstHitChance = battleInput.Participant1HitChance!; + secondHitChance = battleInput.Participant2HitChance!; + firstHitSeverity = battleInput.Participant1DamageSeverity!; + secondHitSeverity = battleInput.Participant2DamageSeverity!; + } + else + { + secondHitter = participant1; + firstHitChance = battleInput.Participant2HitChance!; + secondHitChance = battleInput.Participant1HitChance!; + firstHitSeverity = battleInput.Participant2DamageSeverity!; + secondHitSeverity = battleInput.Participant1DamageSeverity!; + } + + return ResolveCombat(firstHitter, secondHitter, firstHitChance, secondHitChance, firstHitSeverity, + secondHitSeverity) + $" {firstHitter.Name} and {secondHitter.Name}'s battle has " + + "been resolved and this pair can not be used for the battle tool again."; + } + catch (Exception) + { + return + "Could not execute the battle. Tool input format was invalid. " + + "Please provide the input in valid JSON."; + } + } + + private static string ResolveCombat(Character firstHitter, Character secondHitter, string firstHitChance, + string secondHitChance, string firstHitSeverity, string secondHitSeverity) + { + var resultString = $"{firstHitter.Name} described as \"{firstHitter.Description}\" fights " + + $"{secondHitter.Name} described as \"{secondHitter.Description}\"\n"; + + + resultString += ResolveAttack(firstHitter, secondHitter, firstHitChance, firstHitSeverity); + if (secondHitter.CurrentHealth <= 0) + { + return resultString; + } + + resultString += " " + ResolveAttack(secondHitter, firstHitter, secondHitChance, secondHitSeverity); + + return resultString; + } + + private static string ResolveAttack(Character damageDealer, Character damageTaker, string hitChance, + string hitSeverity) + { + var resultString = string.Empty; + Random rand = new Random(); + var doesAttackHit = rand.NextDouble() <= HitChance[hitChance]; + + if (doesAttackHit) + { + var (minDamage, maxDamage) = DamageRanges[hitSeverity]; + var damage = rand.Next(minDamage, maxDamage); + resultString += $"{damageDealer.Name} deals {damage} damage to {damageTaker.Name}. "; + if (damageTaker.AdjustHealth(-damage)) + { + if (damageTaker.IsPlayer) + { + return resultString + $"The player {damageTaker.Name} has no remaining health points. " + + "Their adventure is over. No more actions can be taken."; + } + + return resultString + + $"With no health points remaining, {damageTaker.Name} dies and can no longer " + + "perform actions in the narrative."; + } + + return resultString + + $"They have {damageTaker.CurrentHealth} health points out of {damageTaker.MaxHealth} remaining."; + } + + return $"{damageDealer.Name} misses their attack on {damageTaker.Name}."; + } + + private static Character DetermineFirstHitter(Character participant1, Character participant2) + { + var rand = new Random(); + var firstHitRoll = rand.NextDouble(); + + return (participant1.Type - participant2.Type) switch + { + 0 => firstHitRoll <= 0.5 ? participant1 : participant2, + 1 => firstHitRoll <= 0.4 ? participant1 : participant2, + 2 => firstHitRoll <= 0.3 ? participant1 : participant2, + >= 3 => firstHitRoll <= 0.2 ? participant1 : participant2, + -1 => firstHitRoll <= 0.6 ? participant1 : participant2, + -2 => firstHitRoll <= 0.7 ? participant1 : participant2, + <= -3 => firstHitRoll <= 0.8 ? participant1 : participant2 + }; + } +} diff --git a/ChatRPG/API/Tools/CharacterInput.cs b/ChatRPG/API/Tools/CharacterInput.cs new file mode 100644 index 0000000..ccea124 --- /dev/null +++ b/ChatRPG/API/Tools/CharacterInput.cs @@ -0,0 +1,22 @@ +namespace ChatRPG.API.Tools; + +public class CharacterInput +{ + public string? Name { get; set; } + public string? Description { get; set; } + public string? Type { get; set; } + public string? State { get; set; } + + public bool IsValidForBattle(out List validationErrors) + { + validationErrors = []; + + if (string.IsNullOrWhiteSpace(Name)) + validationErrors.Add("Name is required."); + + if (string.IsNullOrWhiteSpace(Description)) + validationErrors.Add("Description is required."); + + return validationErrors.Count == 0; + } +} diff --git a/ChatRPG/API/Tools/UpdateEnvironmentInput.cs b/ChatRPG/API/Tools/EnvironmentInput.cs similarity index 81% rename from ChatRPG/API/Tools/UpdateEnvironmentInput.cs rename to ChatRPG/API/Tools/EnvironmentInput.cs index 1231570..6ec693f 100644 --- a/ChatRPG/API/Tools/UpdateEnvironmentInput.cs +++ b/ChatRPG/API/Tools/EnvironmentInput.cs @@ -1,6 +1,6 @@ namespace ChatRPG.API.Tools; -public class UpdateEnvironmentInput +public class EnvironmentInput { public string? Name { get; set; } public string? Description { get; set; } diff --git a/ChatRPG/API/Tools/HealCharacterTool.cs b/ChatRPG/API/Tools/HealCharacterTool.cs index dabb893..881a20b 100644 --- a/ChatRPG/API/Tools/HealCharacterTool.cs +++ b/ChatRPG/API/Tools/HealCharacterTool.cs @@ -28,7 +28,7 @@ public class HealCharacterTool( { try { - var healInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var healInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("HealCharacterInstruction")!; @@ -54,7 +54,7 @@ public class HealCharacterTool( catch (Exception) { return "Could not determine the character to heal. Tool input format was invalid. " + - "Please provide a valid character name, description, and magnitude level in valid JSON without markdown."; + "Please provide a valid character name, description, and magnitude level in valid JSON."; } } } diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 14e280f..056d7e2 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -10,6 +10,8 @@ namespace ChatRPG.API.Tools; public class ToolUtilities(IConfiguration configuration) { + private const int IncludedPreviousMessages = 4; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true @@ -18,7 +20,7 @@ public class ToolUtilities(IConfiguration configuration) public async Task FindCharacter(Campaign campaign, string input, string instruction) { var provider = new OpenAiProvider(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); - var llm = new Gpt4Model(provider) + var llm = new Gpt4OmniModel(provider) { Settings = new OpenAiChatSettings() { UseStreaming = false } }; @@ -30,7 +32,12 @@ public class ToolUtilities(IConfiguration configuration) query.Append($"\n\nThe story up until now: {campaign.GameSummary}"); - query.Append($"\n\nThe player's newest action: {input}"); + var content = campaign.Messages.TakeLast(IncludedPreviousMessages).Select(m => m.Content); + query.Append("\n\nUse these previous messages as context:"); + foreach (var message in content) + { + query.Append($"\n {message}"); + } query.Append("\n\nHere is the list of all characters present in the story:\n\n{\"characters\": [\n"); @@ -44,12 +51,17 @@ public class ToolUtilities(IConfiguration configuration) query.Append("\n]}"); + query.Append($"\n\nThe player is {campaign.Player.Name}. First-person pronouns refer to them."); + + query.Append($"\n\nFind the character using the following content: {input}."); + var response = await llm.GenerateAsync(query.ToString()); try { var llmResponseCharacter = - JsonSerializer.Deserialize(response.ToString(), JsonOptions); + JsonSerializer.Deserialize(RemoveMarkdown(response.ToString()), + JsonOptions); if (llmResponseCharacter is null) return null; @@ -74,4 +86,15 @@ public class ToolUtilities(IConfiguration configuration) return null; // Format was unexpected } } -} + + public static string RemoveMarkdown(string text) + { + if (text.StartsWith("```json") && text.EndsWith("```")) + { + text = text.Replace("```json", ""); + text = text.Replace("```", ""); + } + + return text; + } +} \ No newline at end of file diff --git a/ChatRPG/API/Tools/UpdateCharacterInput.cs b/ChatRPG/API/Tools/UpdateCharacterInput.cs deleted file mode 100644 index 17eaf9a..0000000 --- a/ChatRPG/API/Tools/UpdateCharacterInput.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ChatRPG.API.Tools; - -public class UpdateCharacterInput -{ - public string? Name { get; set; } - public string? Description { get; set; } - public string? Type { get; set; } - public string? State { get; set; } -} diff --git a/ChatRPG/API/Tools/UpdateCharacterTool.cs b/ChatRPG/API/Tools/UpdateCharacterTool.cs index 999b969..883d9ef 100644 --- a/ChatRPG/API/Tools/UpdateCharacterTool.cs +++ b/ChatRPG/API/Tools/UpdateCharacterTool.cs @@ -19,7 +19,7 @@ public class UpdateCharacterTool( { try { - var updateCharacterInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var updateCharacterInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); if (updateCharacterInput.Name.IsNullOrEmpty()) { @@ -74,7 +74,7 @@ public class UpdateCharacterTool( catch (JsonException) { return Task.FromResult("Could not determine the character to update. Tool input format was invalid. " + - "Please provide a valid character name, description, type, and state in valid JSON without markdown."); + "Please provide a valid character name, description, type, and state in valid JSON."); } } diff --git a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs index b7e8b8d..8b3174f 100644 --- a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs +++ b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs @@ -20,7 +20,7 @@ public class UpdateEnvironmentTool( { try { - var updateEnvironmentInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var updateEnvironmentInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); if (updateEnvironmentInput.Name.IsNullOrEmpty()) @@ -73,7 +73,7 @@ public class UpdateEnvironmentTool( { return Task.FromResult("Could not determine the environment to update. Tool input format was invalid. " + "Please provide a valid environment name, description, and determine if the " + - "player character is present in the environment in valid JSON without markdown."); + "player character is present in the environment in valid JSON."); } } } diff --git a/ChatRPG/API/Tools/WoundCharacterTool.cs b/ChatRPG/API/Tools/WoundCharacterTool.cs index 3880dfc..4fdc44f 100644 --- a/ChatRPG/API/Tools/WoundCharacterTool.cs +++ b/ChatRPG/API/Tools/WoundCharacterTool.cs @@ -29,11 +29,11 @@ public class WoundCharacterTool( { try { - var effectInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var woundInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("WoundCharacterInstruction")!; - var character = await utilities.FindCharacter(campaign, effectInput.Input!, instruction); + var character = await utilities.FindCharacter(campaign, woundInput.Input!, instruction); if (character is null) { @@ -43,7 +43,7 @@ public class WoundCharacterTool( // Determine damage Random rand = new Random(); - var (minDamage, maxDamage) = DamageRanges[effectInput.Severity!]; + var (minDamage, maxDamage) = DamageRanges[woundInput.Severity!]; var damage = rand.Next(minDamage, maxDamage); if (character.AdjustHealth(-damage)) @@ -70,7 +70,7 @@ public class WoundCharacterTool( catch (Exception) { return "Could not determine the character to wound. Tool input format was invalid. " + - "Please provide a valid character name, description, and severity level in valid JSON without markdown."; + "Please provide a valid character name, description, and severity level in valid JSON."; } } } diff --git a/ChatRPG/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/ChatRPG/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs index 3b66fbb..be24f0f 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs +++ b/ChatRPG/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. #nullable disable -using System; using Microsoft.AspNetCore.Mvc.Rendering; namespace ChatRPG.Areas.Identity.Pages.Account.Manage diff --git a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml index ae1b232..52afb0e 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml +++ b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml @@ -1,5 +1,4 @@ @page "/Register" -@using Microsoft.AspNetCore.Authentication @model RegisterModel @{ ViewData["Title"] = "Register"; diff --git a/ChatRPG/Data/Models/Character.cs b/ChatRPG/Data/Models/Character.cs index e44f4a4..0796342 100644 --- a/ChatRPG/Data/Models/Character.cs +++ b/ChatRPG/Data/Models/Character.cs @@ -6,7 +6,8 @@ private Character() { } - public Character(Campaign campaign, Environment environment, CharacterType type, string name, string description, bool isPlayer) + public Character(Campaign campaign, Environment environment, CharacterType type, string name, string description, + bool isPlayer) { Campaign = campaign; Environment = environment; @@ -16,10 +17,11 @@ public Character(Campaign campaign, Environment environment, CharacterType type, IsPlayer = isPlayer; MaxHealth = type switch { - CharacterType.Humanoid => 50, - CharacterType.SmallCreature => 30, - CharacterType.LargeCreature => 70, - CharacterType.Monster => 90, + CharacterType.Humanoid => 40, + CharacterType.SmallMonster => 15, + CharacterType.MediumMonster => 35, + CharacterType.LargeMonster => 55, + CharacterType.BossMonster => 90, _ => 50 }; if (isPlayer) diff --git a/ChatRPG/Data/Models/CharacterType.cs b/ChatRPG/Data/Models/CharacterType.cs index 96df948..468ca84 100644 --- a/ChatRPG/Data/Models/CharacterType.cs +++ b/ChatRPG/Data/Models/CharacterType.cs @@ -2,8 +2,9 @@ public enum CharacterType { + SmallMonster, Humanoid, - SmallCreature, - LargeCreature, - Monster + MediumMonster, + LargeMonster, + BossMonster } diff --git a/ChatRPG/Pages/CampaignPage.razor b/ChatRPG/Pages/CampaignPage.razor index a9ff88e..cd691c8 100644 --- a/ChatRPG/Pages/CampaignPage.razor +++ b/ChatRPG/Pages/CampaignPage.razor @@ -20,31 +20,37 @@ -
-

Location

-
-
-
@_currentLocation?.Name
-
-

@_currentLocation?.Description

+ @if (_currentLocation != null) + { +
+

Location

+
+
+
@_currentLocation?.Name
+
+

@_currentLocation?.Description

+
-
-
-

Characters

-
- @foreach (Character character in _npcList) - { -
-
-
@character.Name
-
-

@character.Description

+ } + @if (_npcList.Any()) + { +
+

Characters

+
+ @foreach (Character character in _npcList) + { +
+
+
@character.Name
+
+

@character.Description

+
-
- } + } +
-
+ }
@@ -70,7 +76,7 @@
- @@ -88,17 +94,10 @@
- - @if (_hasScrollBar) - { - - }
-
+

@_mainCharacter?.Name

@@ -115,6 +114,22 @@
+ +
+ @if (_isArchiving) + { + + Archiving campaign... + + + } +
+ @if (_hasScrollBar) + { + + }
diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 9da4087..840acde 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -19,6 +19,7 @@ public partial class CampaignPage private List _conversation = new(); private string _userInput = ""; private bool _isWaitingForResponse; + private bool _isArchiving; private const string BottomId = "bottom-id"; private Campaign? _campaign; private List _npcList = new(); @@ -26,6 +27,7 @@ public partial class CampaignPage private Character? _mainCharacter; private UserPromptType _activeUserPromptType = UserPromptType.Do; private string _userInputPlaceholder = InputPlaceholder[UserPromptType.Do]; + private bool _pageInitialized = false; private static readonly Dictionary InputPlaceholder = new() { @@ -74,10 +76,8 @@ protected override async Task OnInitializedAsync() GameInputHandler!.ChatCompletionReceived += OnChatCompletionReceived; GameInputHandler!.ChatCompletionChunkReceived += OnChatCompletionChunkReceived; - if (_conversation.Count == 0) - { - InitializeCampaign(); - } + GameInputHandler!.CampaignUpdated += OnCampaignUpdated; + _pageInitialized = true; } /// @@ -94,9 +94,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await JsRuntime!.InvokeAsync("import", "./js/detectScrollBar.js"); await ScrollToElement(BottomId); // scroll down to latest message } + + if (_pageInitialized && _conversation.Count == 0) + { + await InitializeCampaign(); + } } - private void InitializeCampaign() + private async Task InitializeCampaign() { string content = $"The player is {_campaign!.Player.Name}, described as \"{_campaign.Player.Description}\"."; if (_campaign.StartScenario != null) @@ -105,24 +110,19 @@ private void InitializeCampaign() } _isWaitingForResponse = true; - OpenAiGptMessage message = new(MessageRole.System, content); - _conversation.Add(message); try { - GameInputHandler?.HandleInitialPrompt(_campaign, content); + await GameInputHandler!.HandleInitialPrompt(_campaign, content); } - catch (Exception) + catch (Exception e) { + Console.WriteLine($"An error occurred when generating the response: {e.Message}"); _conversation.Add(new OpenAiGptMessage(MessageRole.System, "An error occurred when generating the response \uD83D\uDCA9. " + "Please try again by reloading the campaign.")); _isWaitingForResponse = false; } - finally - { - UpdateStatsUi(); - } } /// @@ -157,17 +157,14 @@ private async Task SendPrompt() await GameInputHandler!.HandleUserPrompt(_campaign, _activeUserPromptType, userInput.Content); _conversation.RemoveAll(m => m.Role.Equals(MessageRole.System)); } - catch (Exception) + catch (Exception e) { + Console.WriteLine($"An error occurred when generating the response: {e.Message}"); _conversation.Add(new OpenAiGptMessage(MessageRole.System, "An error occurred when generating the response \uD83D\uDCA9. Please try again.")); _campaign = await PersistenceService!.LoadFromCampaignIdAsync(_campaign.Id); // Rollback campaign _isWaitingForResponse = false; } - finally - { - UpdateStatsUi(); - } } /// @@ -196,6 +193,7 @@ private void OnChatCompletionReceived(object? sender, ChatCompletionReceivedEven if (eventArgs.Message.Content != string.Empty) { _isWaitingForResponse = false; + _isArchiving = true; StateHasChanged(); } } @@ -211,6 +209,7 @@ private void OnChatCompletionChunkReceived(object? sender, ChatCompletionChunkRe if (eventArgs.IsStreamingDone) { _isWaitingForResponse = false; + _isArchiving = true; StateHasChanged(); } else if (eventArgs.Chunk is not null) @@ -222,6 +221,12 @@ private void OnChatCompletionChunkReceived(object? sender, ChatCompletionChunkRe Task.Run(() => ScrollToElement(BottomId)); } + private async void OnCampaignUpdated() + { + _isArchiving = false; + await InvokeAsync(UpdateStatsUi); + } + private void OnPromptTypeChange(UserPromptType type) { switch (type) @@ -248,9 +253,8 @@ private void OnPromptTypeChange(UserPromptType type) /// private void UpdateStatsUi() { - _npcList = _campaign!.Characters.Where(c => !c.IsPlayer).ToList(); - _npcList.Reverse(); // Show the most newly encountered npc first - _currentLocation = _campaign!.Environments.LastOrDefault(); + _npcList = _campaign!.Characters.Where(c => !c.IsPlayer).OrderByDescending(c => c.Id).ToList(); + _currentLocation = _campaign!.Player.Environment; _mainCharacter = _campaign!.Player; StateHasChanged(); } diff --git a/ChatRPG/Pages/Index.razor b/ChatRPG/Pages/Index.razor index 61068df..de43338 100644 --- a/ChatRPG/Pages/Index.razor +++ b/ChatRPG/Pages/Index.razor @@ -24,7 +24,7 @@
- © 2023 - ChatRPG + © @DateTime.Now.Year - ChatRPG
diff --git a/ChatRPG/Pages/Shared/_Layout.cshtml b/ChatRPG/Pages/Shared/_Layout.cshtml index e3b6257..1a74a18 100644 --- a/ChatRPG/Pages/Shared/_Layout.cshtml +++ b/ChatRPG/Pages/Shared/_Layout.cshtml @@ -53,7 +53,7 @@