diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b73e2a8..5ecbfbe 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,3 +1,4 @@ +--- name: Build on: diff --git a/ChatRPG.sln b/ChatRPG.sln index de195e9..b49c903 100644 --- a/ChatRPG.sln +++ b/ChatRPG.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.5.33627.172 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatRPG", "ChatRPG\ChatRPG.csproj", "{02177064-5C6F-4AD0-A3E2-8FDE212D1B8E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatRPGTests", "ChatRPGTests\ChatRPGTests.csproj", "{38AA38FA-5260-4499-8448-97F1D962035B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {02177064-5C6F-4AD0-A3E2-8FDE212D1B8E}.Debug|Any CPU.Build.0 = Debug|Any CPU {02177064-5C6F-4AD0-A3E2-8FDE212D1B8E}.Release|Any CPU.ActiveCfg = Release|Any CPU {02177064-5C6F-4AD0-A3E2-8FDE212D1B8E}.Release|Any CPU.Build.0 = Release|Any CPU + {38AA38FA-5260-4499-8448-97F1D962035B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38AA38FA-5260-4499-8448-97F1D962035B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38AA38FA-5260-4499-8448-97F1D962035B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38AA38FA-5260-4499-8448-97F1D962035B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ChatRPG/Areas/Identity/Pages/Account/LogOut.cshtml b/ChatRPG/Areas/Identity/Pages/Account/LogOut.cshtml index 0addf87..564457f 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/LogOut.cshtml +++ b/ChatRPG/Areas/Identity/Pages/Account/LogOut.cshtml @@ -1,14 +1,29 @@ -@page +@page "/Logout" @attribute [IgnoreAntiforgeryToken] -@inject SignInManager SignInManager +@inject SignInManager SignInManager + @functions { + public async Task OnPost() + { + await SignOut(); + + return Redirect("~/"); + } + + public async Task OnGet() + { + await SignOut(); + + return Redirect("~/"); + } + + private async Task SignOut() { if (SignInManager.IsSignedIn(User)) { await SignInManager.SignOutAsync(); } - - return Redirect("~/"); } -} + +} \ No newline at end of file diff --git a/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml b/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml index 6946e7c..94a62af 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml +++ b/ChatRPG/Areas/Identity/Pages/Account/Login.cshtml @@ -1,4 +1,4 @@ -@page +@page "/Login" @using Microsoft.AspNetCore.Authentication @model LoginModel diff --git a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml index 0fdd818..c7540d7 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml +++ b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml @@ -1,4 +1,4 @@ -@page +@page "/Register" @using Microsoft.AspNetCore.Authentication @model RegisterModel @{ diff --git a/ChatRPG/ChatRPG.csproj b/ChatRPG/ChatRPG.csproj index f93f0ed..3af2d77 100644 --- a/ChatRPG/ChatRPG.csproj +++ b/ChatRPG/ChatRPG.csproj @@ -28,4 +28,8 @@ + + + + diff --git a/ChatRPG/Data/ApplicationDbContext.cs b/ChatRPG/Data/ApplicationDbContext.cs index ea7b008..c341acd 100644 --- a/ChatRPG/Data/ApplicationDbContext.cs +++ b/ChatRPG/Data/ApplicationDbContext.cs @@ -28,7 +28,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Entity(builder => { builder.HasKey("CharacterId", "AbilityId"); - builder.HasOne(c => c.Character).WithMany(c => c.CharacterAbilities); + builder.HasOne(c => c.Character).WithMany(c => c.CharacterAbilities!); }) .Entity(builder => builder.Property(x => x.Ordering).HasDefaultValue(1)) ; diff --git a/ChatRPG/Data/FileUtility.cs b/ChatRPG/Data/FileUtility.cs index 5ee6b10..99c9cc9 100644 --- a/ChatRPG/Data/FileUtility.cs +++ b/ChatRPG/Data/FileUtility.cs @@ -2,9 +2,7 @@ namespace ChatRPG.Data; -public record MessagePair(string PlayerMessage, string AssistantMessage); - -public class FileUtility +public class FileUtility : IFileUtility { private readonly string _currentUser; private readonly string _path; @@ -14,6 +12,11 @@ public class FileUtility private readonly string _playerKeyword = "#: "; private readonly string _gameKeyword = "#: "; + /// + /// Initializes a new instance of the FileUtility class with the specified user and save directory. + /// + /// The username of the current user. + /// The directory where conversation files are saved (default: "Saves/"). public FileUtility(string currentUser, string saveDir = "Saves/") { _currentUser = currentUser; @@ -21,6 +24,7 @@ public FileUtility(string currentUser, string saveDir = "Saves/") _path = SetPath(DateTime.Now); } + /// public async Task UpdateSaveFileAsync(MessagePair messages) { // According to .NET docs, you do not need to check if directory exists first @@ -37,12 +41,19 @@ public async Task UpdateSaveFileAsync(MessagePair messages) await fs.WriteAsync(encodedAssistantMessage, 0, encodedAssistantMessage.Length); } + /// public async Task> GetMostRecentConversationAsync(string playerTag, string assistantTag) { string filePath = GetMostRecentFile(Directory.GetFiles(_saveDir, $"{_currentUser}*")); return await GetConversationsStringFromSaveFileAsync(filePath, playerTag, assistantTag); } + /// + /// Prepares a message for saving by ensuring a newline character and specifying the author. + /// + /// The message to prepare for saving. + /// A boolean indicating if the message is from the player (default: false). + /// The prepared message with a newline character and author tag. private string PrepareMessageForSave(string message, bool isPlayerMessage = false) { if (!message.EndsWith("\n")) @@ -52,6 +63,13 @@ private string PrepareMessageForSave(string message, bool isPlayerMessage = fals return message; } + /// + /// Reads a conversation string from a save file asynchronously and converts it into a list of messages. + /// + /// The path to the save file to read. + /// The tag to mark a player message. + /// The tag to mark an assistant message. + /// A list of messages extracted from the save file. private async Task> GetConversationsStringFromSaveFileAsync(string path, string playerTag, string assistantTag) { @@ -70,6 +88,11 @@ private async Task> GetConversationsStringFromSaveFileAsync(string return ConvertConversationStringToList(sb.ToString(), playerTag, assistantTag); } + /// + /// Gets the most recent file from a list of files based on their timestamps. + /// + /// An array of file paths to choose from. + /// The path to the most recent file. private string GetMostRecentFile(string[] files) { DateTime mostRecent = new DateTime(1, 1, 1, 0, 0, 0); // Hello Jesus @@ -130,6 +153,11 @@ private List ConvertConversationStringToList(string conversation, string return fullConversation; } + /// + /// Sets the path for a save file based on the user and a timestamp. + /// + /// The timestamp used to create the file name. + /// The complete path to the save file. private string SetPath(DateTime timestamp) { // Add save directory and file name to path diff --git a/ChatRPG/Data/IFileUtility.cs b/ChatRPG/Data/IFileUtility.cs new file mode 100644 index 0000000..1473cc0 --- /dev/null +++ b/ChatRPG/Data/IFileUtility.cs @@ -0,0 +1,21 @@ +namespace ChatRPG.Data; + +public interface IFileUtility +{ + /// + /// Updates the conversation save file asynchronously with the provided message pair. + /// + /// A pair of messages (player and assistant) to save in the file. + /// A task representing the asynchronous file update process. + Task UpdateSaveFileAsync(MessagePair messages); + /// + /// Retrieves the most recent conversation from a save file asynchronously, parsing and inserting + /// into messages the player and assistant tags. + /// + /// The tag to mark a player message. + /// The tag to mark an assistant message. + /// A list of messages representing the most recent conversation. + Task> GetMostRecentConversationAsync(string playerTag, string assistantTag); +} + +public record MessagePair(string PlayerMessage, string AssistantMessage); diff --git a/ChatRPG/Data/Models/User.cs b/ChatRPG/Data/Models/User.cs index 6bb0fef..4767e09 100644 --- a/ChatRPG/Data/Models/User.cs +++ b/ChatRPG/Data/Models/User.cs @@ -4,7 +4,7 @@ namespace ChatRPG.Data.Models; public class User : IdentityUser { - private User() + public User() { } @@ -12,5 +12,5 @@ public User(string username) : base(username) { } - public ICollection Campaigns { get; } = new List(); + public virtual ICollection Campaigns { get; } = new List(); } diff --git a/ChatRPG/Pages/Campaign.razor b/ChatRPG/Pages/Campaign.razor index 4aba160..a0a2797 100644 --- a/ChatRPG/Pages/Campaign.razor +++ b/ChatRPG/Pages/Campaign.razor @@ -1,16 +1,12 @@ @page "/Campaign" -@using ChatRPG.Data -@using ChatRPG.API @using Microsoft.IdentityModel.Tokens @using OpenAiGptMessage = ChatRPG.API.OpenAiGptMessage -@inject IConfiguration Configuration -@inject IOpenAiLlmClient OpenAiLlmClient; -@inject IJSRuntime JsRuntime -@inject AuthenticationStateProvider AuthenticationStateProvider Campaign -
+ + +

Campaign

@@ -34,7 +30,7 @@
-
@@ -43,96 +39,8 @@ -@code { - private string? _loggedInUsername; - private bool _shouldSave; - private IJSObjectReference? _scrollJsScript; - private FileUtility? _fileUtil; - readonly List _conversation = new(); - private string _userInput = ""; - private string _tempMessage = ""; - private bool _shouldStream; - private bool _isWaitingForResponse; - - protected override async Task OnInitializedAsync() - { - AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); - _loggedInUsername = authenticationState.User.Identity?.Name; - if (_loggedInUsername != null) _fileUtil = new FileUtility(_loggedInUsername); - _shouldSave = Configuration.GetValue("SaveConversationsToFile"); - _shouldStream = !Configuration.GetValue("UseMocks") && Configuration.GetValue("StreamChatCompletions"); - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - _scrollJsScript ??= await JsRuntime.InvokeAsync("import", "./js/scroll.js"); - } - } - - async Task EnterKeyHandler(KeyboardEventArgs e) - { - if (e.Code is "Enter" or "NumpadEnter") - { - await SendPrompt(); - } - } - - private async Task SendPrompt() - { - if (string.IsNullOrWhiteSpace(_userInput)) - { - return; - } - _isWaitingForResponse = true; - - OpenAiGptMessage userInput = new OpenAiGptMessage("user", _userInput); - _conversation.Add(userInput); - - if (_shouldStream) - { - await HandleStreamedResponse(OpenAiLlmClient.GetStreamedChatCompletion(userInput)); - } - else - { - string response = await OpenAiLlmClient.GetChatCompletion(userInput); - HandleResponse(response); - } - - if (_shouldSave && _fileUtil != null) - { - string assistantOutput = _conversation.Last().Content; - await _fileUtil.UpdateSaveFileAsync(new MessagePair(_userInput, assistantOutput)); - } - - _userInput = ""; - StateHasChanged(); - await ScrollToElement("bottom-id"); - _isWaitingForResponse = false; - } - - private void HandleResponse(string response) - { - OpenAiGptMessage assistantOutput = new OpenAiGptMessage("assistant", response); - _conversation.Add(assistantOutput); - } - - private async Task HandleStreamedResponse(IAsyncEnumerable streamedResponse) - { - await foreach (string res in streamedResponse) - { - _tempMessage += res; - StateHasChanged(); - await ScrollToElement("bottom-id"); - } - HandleResponse(_tempMessage); - _tempMessage = ""; - } - - private async Task ScrollToElement(string elementId) - { - await _scrollJsScript!.InvokeVoidAsync("ScrollToId", elementId); - } - -} +
+
+ © 2023 - ChatRPG +
+
\ No newline at end of file diff --git a/ChatRPG/Pages/Campaign.razor.cs b/ChatRPG/Pages/Campaign.razor.cs new file mode 100644 index 0000000..e4983c4 --- /dev/null +++ b/ChatRPG/Pages/Campaign.razor.cs @@ -0,0 +1,139 @@ +using ChatRPG.Data; +using ChatRPG.API; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; +using OpenAiGptMessage = ChatRPG.API.OpenAiGptMessage; + +namespace ChatRPG.Pages; + +public partial class Campaign +{ + private string? _loggedInUsername; + private bool _shouldSave; + private IJSObjectReference? _scrollJsScript; + private FileUtility? _fileUtil; + readonly List _conversation = new(); + private string _userInput = ""; + private string _tempMessage = ""; + private bool _shouldStream; + private bool _isWaitingForResponse; + + [Inject] private IConfiguration? Configuration { get; set; } + [Inject] private IOpenAiLlmClient? OpenAiLlmClient { get; set; } + [Inject] private IJSRuntime? JsRuntime { get; set; } + [Inject] private AuthenticationStateProvider? AuthenticationStateProvider { get; set; } + + /// + /// Initializes the Campaign page component by setting up configuration parameters. + /// + /// A task that represents the asynchronous initialization process. + protected override async Task OnInitializedAsync() + { + AuthenticationState authenticationState = await AuthenticationStateProvider!.GetAuthenticationStateAsync(); + _loggedInUsername = authenticationState.User.Identity?.Name; + if (_loggedInUsername != null) _fileUtil = new FileUtility(_loggedInUsername); + _shouldSave = Configuration!.GetValue("SaveConversationsToFile"); + _shouldStream = !Configuration!.GetValue("UseMocks") && + Configuration!.GetValue("StreamChatCompletions"); + } + + /// + /// Executes after the component has rendered and initializes JavaScript interop for scrolling. + /// + /// A boolean indicating if it is the first rendering of the component. + /// A task representing the asynchronous rendering process. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _scrollJsScript ??= await JsRuntime!.InvokeAsync("import", "./js/scroll.js"); + } + } + + /// + /// Handles the Enter key press event and sends the user input as a prompt to the LLM API. + /// + /// A KeyboardEventArgs representing the keyboard event. + async Task EnterKeyHandler(KeyboardEventArgs e) + { + if (e.Code is "Enter" or "NumpadEnter") + { + await SendPrompt(); + } + } + + /// + /// Sends the user input as a prompt to the AI model, handles the response, and updates the conversation UI. + /// + private async Task SendPrompt() + { + if (string.IsNullOrWhiteSpace(_userInput)) + { + return; + } + + _isWaitingForResponse = true; + + OpenAiGptMessage userInput = new OpenAiGptMessage("user", _userInput); + _conversation.Add(userInput); + + if (_shouldStream) + { + await HandleStreamedResponse(OpenAiLlmClient!.GetStreamedChatCompletion(userInput)); + } + else + { + string response = await OpenAiLlmClient!.GetChatCompletion(userInput); + HandleResponse(response); + } + + if (_shouldSave && _fileUtil != null) + { + string assistantOutput = _conversation.Last().Content; + await _fileUtil.UpdateSaveFileAsync(new MessagePair(_userInput, assistantOutput)); + } + + _userInput = ""; + StateHasChanged(); + await ScrollToElement("bottom-id"); + _isWaitingForResponse = false; + } + + /// + /// Handles the AI model's response and updates the conversation UI with the assistant's message. + /// + /// The response received from the AI model. + private void HandleResponse(string response) + { + OpenAiGptMessage assistantOutput = new OpenAiGptMessage("assistant", response); + _conversation.Add(assistantOutput); + } + + /// + /// Handles a streamed response from the AI model and updates the conversation UI with the assistant's message. + /// + /// An asynchronous stream of responses from the AI model. + private async Task HandleStreamedResponse(IAsyncEnumerable streamedResponse) + { + await foreach (string res in streamedResponse) + { + _tempMessage += res; + StateHasChanged(); + await ScrollToElement("bottom-id"); + } + + HandleResponse(_tempMessage); + _tempMessage = ""; + } + + /// + /// Scrolls to the specified document element using JavaScript interop. + /// + /// The ID of the element to scroll to. + private async Task ScrollToElement(string elementId) + { + await _scrollJsScript!.InvokeVoidAsync("ScrollToId", elementId); + } +} diff --git a/ChatRPG/Pages/Index.razor b/ChatRPG/Pages/Index.razor index 6085c4a..5a8e2fe 100644 --- a/ChatRPG/Pages/Index.razor +++ b/ChatRPG/Pages/Index.razor @@ -1,9 +1,33 @@ @page "/" -Index + + + ChatRPG -

Hello, world!

+
+

+ @_titleDisplayText| +

+

Immerse yourself in the ultimate AI-powered adventure!

+ +
+
+ + Dashboard - ChatRPG -Welcome to your new app. +
- + + +
+ +
+ +
+
+ © 2023 - ChatRPG +
+
+
+
+
diff --git a/ChatRPG/Pages/Index.razor.cs b/ChatRPG/Pages/Index.razor.cs new file mode 100644 index 0000000..8025bae --- /dev/null +++ b/ChatRPG/Pages/Index.razor.cs @@ -0,0 +1,73 @@ +namespace ChatRPG.Pages; + +public partial class Index : IDisposable +{ + private readonly string _fullTitleText = "ChatRPG"; + private string _titleDisplayText = ""; + private bool _cursorVisible = true; + private CancellationTokenSource? _cts; + + /// + /// Initializes the component and starts the cursor blinking. + /// + /// A task that represents the asynchronous initialization process. + protected override async Task OnInitializedAsync() + { + _cts = new CancellationTokenSource(); + // Start cursor blinking on a separate thread + await Task.Factory.StartNew(() => BlinkCursorAsync(_cts.Token), _cts.Token, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + /// + /// Executes after the component has rendered and starts the typing animation. + /// + /// A boolean indicating if it is the first rendering of the component. + /// A task representing the asynchronous rendering process. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await TypingAnimateAsync(_cts!.Token); + } + } + + /// + /// Animates the typing effect by gradually revealing characters of the full title text. + /// + /// A cancellation token to cease the animation's progress at a specific time. + /// A task representing the asynchronous animation process. + private async Task TypingAnimateAsync(CancellationToken cancellationToken) + { + for (int i = 0; i <= _fullTitleText.Length; i++) + { + _titleDisplayText = _fullTitleText.Substring(0, i); + await Task.Delay(100, cancellationToken); // Adjust the typing delay as needed + await InvokeAsync(StateHasChanged); + } + } + + /// + /// Animates the cursor blinking effect. + /// + /// A cancellation token to cease the cursor animation's progress at a specific time. + /// A task representing the asynchronous cursor animation process. + private async Task BlinkCursorAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + _cursorVisible = !_cursorVisible; // Toggle cursor visibility + await Task.Delay(500, cancellationToken); // Adjust the delay between blinks + await InvokeAsync(StateHasChanged); + } + } + + /// + /// Disposes of the component and cancels any ongoing animation tasks. + /// + public void Dispose() + { + _cts?.Cancel(); // Request cancellation when the component is about to be removed + _cts?.Dispose(); + } +} diff --git a/ChatRPG/Pages/Navbar.razor b/ChatRPG/Pages/Navbar.razor new file mode 100644 index 0000000..891583e --- /dev/null +++ b/ChatRPG/Pages/Navbar.razor @@ -0,0 +1,31 @@ +@namespace ChatRPG.Pages +@inherits ComponentBase + +
+ +
+ +@code { + + [Parameter, EditorRequired] + public string Username { get; set; } = null!; + + [Parameter] + public string WelcomeMessage { get; set; } = "Welcome"; + +} \ No newline at end of file diff --git a/ChatRPG/Pages/UserCampaignOverview.razor b/ChatRPG/Pages/UserCampaignOverview.razor new file mode 100644 index 0000000..4d86351 --- /dev/null +++ b/ChatRPG/Pages/UserCampaignOverview.razor @@ -0,0 +1,66 @@ +@using ChatRPG.Data.Models +@using CampaignModel = ChatRPG.Data.Models.Campaign; + +
+ @if (User is not null) + { +
+
+
+

Your Campaigns

+
+ @foreach (CampaignModel campaign in Campaigns) + { +
+
+
@campaign.Title
+
@campaign.Characters.First(c => c.IsPlayer).Name
+

@(campaign.CustomStartScenario ?? campaign.StartScenario?.Body ?? "No scenario")

+

Started @campaign.StartedOn.ToShortDateString() @campaign.StartedOn.ToShortTimeString()

+ See more > +
+
+ } +
+

Found @(Campaigns.Count) campaign@(Campaigns.Count == 1 ? "" : "s").

+
+
+
+
+

Start Scenarios

+ @foreach (StartScenario scenario in StartScenarios) + { +
+
+
@scenario.Title
+

@scenario.Body

+ See more > +
+
+ } +

Found @(StartScenarios.Count) scenario@(StartScenarios.Count == 1 ? "" : "s").

+
+
+
+
+

Create a Custom Campaign

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ } +
diff --git a/ChatRPG/Pages/UserCampaignOverview.razor.cs b/ChatRPG/Pages/UserCampaignOverview.razor.cs new file mode 100644 index 0000000..1a25172 --- /dev/null +++ b/ChatRPG/Pages/UserCampaignOverview.razor.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Authentication; +using ChatRPG.Data.Models; +using ChatRPG.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Environment = ChatRPG.Data.Models.Environment; +using CampaignModel = ChatRPG.Data.Models.Campaign; + +namespace ChatRPG.Pages; + +public partial class UserCampaignOverview : ComponentBase +{ + private User? User { get; set; } = null; + private List Campaigns { get; set; } = new(); + private List StartScenarios { get; set; } = new(); + + [Required][BindProperty] private string CharacterName { get; set; } = ""; + + [Required][BindProperty] private string CampaignTitle { get; set; } = ""; + + [BindProperty] private string CustomStartScenario { get; set; } = null!; + + [Inject] private AuthenticationStateProvider? AuthProvider { get; set; } + [Inject] private UserManager? UserManager { get; set; } + [Inject] private IPersisterService? PersisterService { get; set; } + + protected override async Task OnInitializedAsync() + { + AuthenticationState authState = await AuthProvider!.GetAuthenticationStateAsync(); + User = await UserManager!.GetUserAsync(authState.User) + ?? throw new AuthenticationException("User is not authorized"); + Campaigns = await PersisterService!.GetCampaignsForUser(User); + Campaigns.Reverse(); // Reverse to display latest campaign first + StartScenarios = await PersisterService.GetStartScenarios(); + } + + private async Task CreateAndStartCampaign() + { + if (User is null) + { + throw new Exception(); + } + + CampaignModel campaign = new(User, CampaignTitle, CustomStartScenario); + Character player = new Character(campaign, CharacterType.Humanoid, CharacterName, "", true, 100); + Environment environment = new(campaign, "Start location", "The place where it all began"); + player.Environment = environment; + campaign.Environments.Add(environment); + campaign.Characters.Add(player); + await PersisterService!.SaveAsync(campaign); + // TODO: Redirect to campaign page + CharacterName = ""; + CampaignTitle = ""; + CustomStartScenario = null!; + } +} diff --git a/ChatRPG/Program.cs b/ChatRPG/Program.cs index d92bc65..f24902c 100644 --- a/ChatRPG/Program.cs +++ b/ChatRPG/Program.cs @@ -17,6 +17,7 @@ builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddDefaultIdentity() + .AddSignInManager>() .AddEntityFrameworkStores(); builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); diff --git a/ChatRPG/Services/EfPersisterService.cs b/ChatRPG/Services/EfPersisterService.cs index 685c23d..1191377 100644 --- a/ChatRPG/Services/EfPersisterService.cs +++ b/ChatRPG/Services/EfPersisterService.cs @@ -51,9 +51,25 @@ public async Task LoadFromCampaignIdAsync(int campaignId) .Include(campaign => campaign.Environments) .Include(campaign => campaign.Events) .Include(campaign => campaign.Characters) - .ThenInclude(character => character.CharacterAbilities) - .ThenInclude(characterAbility => characterAbility.Ability) + .ThenInclude(character => character.CharacterAbilities) + .ThenInclude(characterAbility => characterAbility!.Ability) .AsSplitQuery() .FirstAsync(); } + + /// + public async Task> GetCampaignsForUser(User user) + { + return await _dbContext.Campaigns + .Where(campaign => campaign.User.Equals(user)) + .Include(campaign => campaign.Characters.Where(c => c.IsPlayer)) + .Include(campaign => campaign.StartScenario) + .ToListAsync(); + } + + /// + public async Task> GetStartScenarios() + { + return await _dbContext.StartScenarios.ToListAsync(); + } } diff --git a/ChatRPG/Services/IPersisterService.cs b/ChatRPG/Services/IPersisterService.cs index e74036e..a67540e 100644 --- a/ChatRPG/Services/IPersisterService.cs +++ b/ChatRPG/Services/IPersisterService.cs @@ -19,4 +19,15 @@ public interface IPersisterService /// Id of the campaign to load. /// A task representing the asynchronous operation. Task LoadFromCampaignIdAsync(int campaignId); + /// + /// Loads all campaigns for the given , along with their s and player s. + /// + /// The user whose campaigns to load. + /// A list of the user's campaigns. + Task> GetCampaignsForUser(User user); + /// + /// Loads all start scenarios. + /// + /// A list of all start scenarios. + Task> GetStartScenarios(); } diff --git a/ChatRPG/Shared/LoginDisplay.razor b/ChatRPG/Shared/LoginDisplay.razor index 8590e5e..35b300b 100644 --- a/ChatRPG/Shared/LoginDisplay.razor +++ b/ChatRPG/Shared/LoginDisplay.razor @@ -1,12 +1,20 @@ - +@using ChatRPG.Data.Models +@using Microsoft.AspNetCore.Identity +@inject NavigationManager NavMan + + Hello, @context.User.Identity?.Name! -
- -
+
- Register - Log in +
+ + +
diff --git a/ChatRPG/Shared/MainLayout.razor b/ChatRPG/Shared/MainLayout.razor index 06b88e3..ebf764b 100644 --- a/ChatRPG/Shared/MainLayout.razor +++ b/ChatRPG/Shared/MainLayout.razor @@ -2,19 +2,21 @@ ChatRPG -
- - -
-
- - About + + +
+
+
+ @Body +
+
- -
- @Body -
-
-
+ + +
+
+ @Body +
+
+
+
diff --git a/ChatRPG/Shared/NavMenu.razor b/ChatRPG/Shared/NavMenu.razor deleted file mode 100644 index 9307910..0000000 --- a/ChatRPG/Shared/NavMenu.razor +++ /dev/null @@ -1,45 +0,0 @@ - - - - -@code { - private bool _collapseNavMenu = true; - - private string? NavMenuCssClass => _collapseNavMenu ? "collapse" : null; - - private void ToggleNavMenu() - { - _collapseNavMenu = !_collapseNavMenu; - } - -} diff --git a/ChatRPG/Shared/NavMenu.razor.css b/ChatRPG/Shared/NavMenu.razor.css deleted file mode 100644 index 604b7a1..0000000 --- a/ChatRPG/Shared/NavMenu.razor.css +++ /dev/null @@ -1,68 +0,0 @@ -.navbar-toggler { - background-color: rgba(255, 255, 255, 0.1); -} - -.top-row { - height: 3.5rem; - background-color: rgba(0,0,0,0.4); -} - -.navbar-brand { - font-size: 1.1rem; -} - -.oi { - width: 2rem; - font-size: 1.1rem; - vertical-align: text-top; - top: -2px; -} - -.nav-item { - font-size: 0.9rem; - padding-bottom: 0.5rem; -} - - .nav-item:first-of-type { - padding-top: 1rem; - } - - .nav-item:last-of-type { - padding-bottom: 1rem; - } - - .nav-item ::deep a { - color: #d7d7d7; - border-radius: 4px; - height: 3rem; - display: flex; - align-items: center; - line-height: 3rem; - } - -.nav-item ::deep a.active { - background-color: rgba(255,255,255,0.25); - color: white; -} - -.nav-item ::deep a:hover { - background-color: rgba(255,255,255,0.1); - color: white; -} - -@media (min-width: 641px) { - .navbar-toggler { - display: none; - } - - .collapse { - /* Never collapse the sidebar for wide screens */ - display: block; - } - - .nav-scrollable { - /* Allow sidebar to scroll for tall menus */ - height: calc(100vh - 3.5rem); - overflow-y: auto; - } -} diff --git a/ChatRPG/Shared/SurveyPrompt.razor b/ChatRPG/Shared/SurveyPrompt.razor deleted file mode 100644 index ec64baa..0000000 --- a/ChatRPG/Shared/SurveyPrompt.razor +++ /dev/null @@ -1,16 +0,0 @@ -
- - @Title - - - Please take our - brief survey - - and tell us what you think. -
- -@code { - // Demonstrates how a parent component can supply parameters - [Parameter] - public string? Title { get; set; } -} diff --git a/ChatRPG/wwwroot/css/site.css b/ChatRPG/wwwroot/css/site.css index 0e25c5d..e4531f6 100644 --- a/ChatRPG/wwwroot/css/site.css +++ b/ChatRPG/wwwroot/css/site.css @@ -68,7 +68,22 @@ a, .btn-link { } .custom-gray-bg { - background-color: rgb(80, 80, 80); + background-color: #3E3A3A; +} + +.custom-gray-container { + background-color: #232323; + color: white; +} + +.custom-gray-container .form-text-black { + color: black; +} + +.btn-send { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; } .user-message-container { @@ -111,3 +126,148 @@ a, .btn-link { .custom-text-field:focus { box-shadow: none; /* Remove any focus box-shadow */ } + +@keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-30px); + } + 60% { + transform: translateY(-15px); + } +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.container-front { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; +} + +.dashboard-wrapper { + display: flex; + flex-direction: column; + height: 100vh; +} + +.dashboard-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow-y: auto; +} + +.title-front { + font-size: 5rem; +} + +.slogan-front { + font-size: 1.5rem; + animation: fadeIn 3s; +} + +.cursor { + position: relative; + top: -0.1em; /* Adjust the value to raise or lower the cursor */ +} + +.main-container { + background-image: url('../images/passage-7685848.jpg'); + background-size: cover; + background-repeat: no-repeat; + background-attachment: fixed; + background-position: center center; +} + +.main-container::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); /* Alpha value for transparency */ +} + +.text-readable { + z-index: 1; /* Ensure the text is above the overlay */ + color: #98d2d2; + text-shadow: 4px 4px 8px rgba(0, 0, 0, 0.5); /* Add a shadow with offset and color */ +} + +.btn-front { + position: relative; + width: 200px; + padding: 15px 0; + text-align: center; + margin: 20px 10px; + font-weight: bold; + border: 2px solid #98d2d2; + background-color: rgb(152, 210, 210, 0.5); + color: #ffffff; + cursor: pointer; + overflow: hidden; + text-transform: uppercase; + animation: fadeIn 3s; +} + +.btn-front-span { + background: #98d2d2; + height: 100%; + width: 0; + position: absolute; + left: 0; + bottom: 0; + z-index: -1; + transition: 0.5s; +} + +.btn-front:hover .btn-front-span { + width: 100%; +} + +.logo-name-group { + display: flex; + align-items: center; + position: relative; +} + +.site-icon { + width: 35px; + height: 35px; + margin-right: 10px; +} + +.campaign-title { + padding-top: 80px; + color: #ffffff; +} + +#scrollbar::-webkit-scrollbar { + width: 12px; +} + +#scrollbar::-webkit-scrollbar-track { + border-radius: 8px; + background-color: #95a5a6; + border: 1px solid #cacaca; +} + +#scrollbar::-webkit-scrollbar-thumb { + border-radius: 8px; + background-color: #3E3A3A; +} diff --git a/ChatRPG/wwwroot/images/passage-7685848.jpg b/ChatRPG/wwwroot/images/passage-7685848.jpg new file mode 100644 index 0000000..ac44ff6 Binary files /dev/null and b/ChatRPG/wwwroot/images/passage-7685848.jpg differ diff --git a/ChatRPGTests/ChatRPGFixture.cs b/ChatRPGTests/ChatRPGFixture.cs new file mode 100644 index 0000000..902a6c5 --- /dev/null +++ b/ChatRPGTests/ChatRPGFixture.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; + +namespace ChatRPGTests; + +public class ChatRPGFixture : IAsyncLifetime +{ + private Process? _appProcess; + + public async Task InitializeAsync() + { + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = "run --project ../../../../ChatRPG/ChatRPG.csproj", + RedirectStandardOutput = false, + RedirectStandardError = false, + UseShellExecute = false, + CreateNoWindow = true + }; + + _appProcess = new Process { StartInfo = startInfo }; + _appProcess.Start(); + await WaitForAppToStart(); + } + + public async Task DisposeAsync() + { + if (!_appProcess!.HasExited) + { + _appProcess.Kill(); + await _appProcess.WaitForExitAsync(); + } + + _appProcess.Dispose(); + } + + private async Task WaitForAppToStart() + { + int maxAttempts = 10; + TimeSpan delayBetweenAttempts = TimeSpan.FromSeconds(2); + + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + // Make a request to a known endpoint + using HttpClient httpClient = new HttpClient(); + HttpResponseMessage response = await httpClient.GetAsync("http://localhost:5111/"); + if (response.IsSuccessStatusCode) + { + return; // App is ready + } + } + catch (HttpRequestException ex) + { + Console.WriteLine($"Request failed: {ex.Message}"); + } + + Task.Delay(delayBetweenAttempts).Wait(); + } + + throw new InvalidOperationException("Failed to determine if the app is ready."); + } +} diff --git a/ChatRPGTests/ChatRPGTests.csproj b/ChatRPGTests/ChatRPGTests.csproj new file mode 100644 index 0000000..41589b6 --- /dev/null +++ b/ChatRPGTests/ChatRPGTests.csproj @@ -0,0 +1,31 @@ + + + + net7.0 + enable + enable + + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/ChatRPGTests/E2ECollection.cs b/ChatRPGTests/E2ECollection.cs new file mode 100644 index 0000000..4fd751f --- /dev/null +++ b/ChatRPGTests/E2ECollection.cs @@ -0,0 +1,11 @@ +[assembly: CollectionBehavior(MaxParallelThreads = 4)] + +namespace ChatRPGTests; + +[CollectionDefinition("E2E collection")] +public class E2ECollection : ICollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} diff --git a/ChatRPGTests/E2ETestUtility.cs b/ChatRPGTests/E2ETestUtility.cs new file mode 100644 index 0000000..fdc8b52 --- /dev/null +++ b/ChatRPGTests/E2ETestUtility.cs @@ -0,0 +1,34 @@ +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; + +namespace ChatRPGTests; + +public static class E2ETestUtility +{ + public static IWebDriver Setup(string page) + { + ChromeOptions chromeOptions = new ChromeOptions(); + chromeOptions.AddArguments("--ignore-certificate-errors", + "--start-maximized", "--disable-popup-blocking", "headless"); + IWebDriver driver = new ChromeDriver(chromeOptions); + driver.Navigate().GoToUrl($"http://localhost:5111{page}"); + + return driver; + } + + public static void Teardown(IWebDriver driver) + { + try + { + driver.Navigate().GoToUrl("http://localhost:5111/Logout"); + } + catch + { + // Was not logged in or unable to log out + // We do not care if it fails during teardown + } + + driver.Close(); + driver.Quit(); + } +} diff --git a/ChatRPGTests/UnauthorizedIndexE2ETests.cs b/ChatRPGTests/UnauthorizedIndexE2ETests.cs new file mode 100644 index 0000000..6a4fe04 --- /dev/null +++ b/ChatRPGTests/UnauthorizedIndexE2ETests.cs @@ -0,0 +1,101 @@ +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; + +namespace ChatRPGTests; + +[Collection("E2E collection")] +public class UnauthorizedIndexE2ETests : IDisposable +{ + private readonly ChatRPGFixture _fixture; + private readonly IWebDriver _driver; + private readonly WebDriverWait _wait; + + public UnauthorizedIndexE2ETests(ChatRPGFixture fixture) + { + _fixture = fixture; + _driver = E2ETestUtility.Setup("/"); + _wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + } + + [Fact] + public void UnauthorizedIndexPage_ContainsTitleText() + { + // Arrange + string expectedTitle = "ChatRPG"; + + // Act + Thread.Sleep(1200); // wait for typing animation to finish + IWebElement? actualTitle = _wait.Until(webDriver => webDriver.FindElement(By.ClassName("title-front"))); + + // Assert + Assert.Contains(expectedTitle, actualTitle.Text); + } + + [Fact] + public void UnauthorizedIndexPage_ContainsSloganText() + { + // Arrange + string expectedSlogan = "Immerse yourself in the ultimate AI-powered adventure!"; + + // Act + Thread.Sleep(1000); // wait for typing animation to finish + IWebElement? actualSlogan = _wait.Until(webDriver => webDriver.FindElement(By.ClassName("slogan-front"))); + + // Assert + Assert.Equal(expectedSlogan, actualSlogan.Text); + } + + [Fact] + public void UnauthorizedIndexPage_ContainsLoginButton() + { + Thread.Sleep(1000); // wait for typing animation to finish + // Act + IWebElement? loginButton = _wait.Until(webDriver => webDriver.FindElement(By.Id("login-button"))); + + // Assert + Assert.True(loginButton.Displayed); + } + + [Fact] + public void UnauthorizedIndexPage_ContainsRegisterButton() + { + Thread.Sleep(1000); // wait for typing animation to finish + // Act + IWebElement? registerButton = _wait.Until(webDriver => webDriver.FindElement(By.Id("register-button"))); + + // Assert + Assert.True(registerButton.Displayed); + } + + [Fact] + public void UnauthorizedIndexPage_PressingLoginButton_ShouldRedirectToLoginPage() + { + Thread.Sleep(1000); // wait for typing animation to finish + // Act + _wait.Until(webDriver => webDriver.FindElement(By.Id("login-button"))).Click(); + string expectedUrl = "http://localhost:5111/Login"; + bool urlRedirects = _wait.Until(webDriver => webDriver.Url == expectedUrl); + + // Assert + Assert.True(urlRedirects); + } + + [Fact] + public void UnauthorizedIndexPage_PressingRegisterButton_ShouldRedirectToRegisterPage() + { + Thread.Sleep(1000); // wait for typing animation to finish + // Act + _wait.Until(webDriver => webDriver.FindElement(By.Id("register-button"))).Click(); + string expectedUrl = "http://localhost:5111/Register"; + bool urlRedirects = _wait.Until(webDriver => webDriver.Url == expectedUrl); + + // Assert + Assert.True(urlRedirects); + } + + public void Dispose() + { + E2ETestUtility.Teardown(_driver); + _driver.Dispose(); + } +} diff --git a/ChatRPGTests/Usings.cs b/ChatRPGTests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/ChatRPGTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit;