diff --git a/backend/WizardRPG.Api/Controllers/CreatureTamingController.cs b/backend/WizardRPG.Api/Controllers/CreatureTamingController.cs new file mode 100644 index 0000000..71b11f6 --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/CreatureTamingController.cs @@ -0,0 +1,71 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.CreatureTaming; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class CreatureTamingController : ControllerBase +{ + private readonly ICreatureTamingService _creatureTamingService; + + public CreatureTamingController(ICreatureTamingService creatureTamingService) + => _creatureTamingService = creatureTamingService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get all available creature types (catalog). + [HttpGet("creatures")] + [AllowAnonymous] + public async Task>> GetCreatures() + { + var creatures = await _creatureTamingService.GetAllCreaturesAsync(); + return Ok(creatures); + } + + /// Get all creatures tamed by the current player. + [HttpGet("my-creatures")] + public async Task>> GetMyCreatures() + { + var creatures = await _creatureTamingService.GetPlayerCreaturesAsync(CurrentPlayerId); + return Ok(creatures); + } + + /// Explore the wilderness to discover a magical creature. Costs 50 gold. + [HttpPost("explore")] + public async Task> Explore() + { + var result = await _creatureTamingService.ExploreAsync(CurrentPlayerId); + return Ok(result); + } + + /// Tame a discovered creature and add it to your collection. + [HttpPost("tame")] + public async Task> Tame([FromBody] TameCreatureRequest request) + { + var creature = await _creatureTamingService.TameCreatureAsync(CurrentPlayerId, request); + return Created($"api/creaturetaming/my-creatures", creature); + } + + /// Care for a tamed creature: feed, train, or let it rest. + [HttpPost("care/{creatureId:guid}")] + public async Task> CareForCreature(Guid creatureId, [FromBody] CareForCreatureRequest request) + { + var result = await _creatureTamingService.CareForCreatureAsync(CurrentPlayerId, creatureId, request); + return Ok(result); + } + + /// Get passive bonuses from all loyal creatures (loyalty > 50). + [HttpGet("bonuses")] + public async Task>> GetBonuses() + { + var bonuses = await _creatureTamingService.GetCreatureBonusesAsync(CurrentPlayerId); + return Ok(bonuses); + } +} diff --git a/backend/WizardRPG.Api/Controllers/DungeonCrawlerController.cs b/backend/WizardRPG.Api/Controllers/DungeonCrawlerController.cs new file mode 100644 index 0000000..9e4467f --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/DungeonCrawlerController.cs @@ -0,0 +1,65 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.DungeonCrawler; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class DungeonCrawlerController : ControllerBase +{ + private readonly IDungeonCrawlerService _dungeonCrawlerService; + + public DungeonCrawlerController(IDungeonCrawlerService dungeonCrawlerService) => _dungeonCrawlerService = dungeonCrawlerService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Start a new dungeon run. + [HttpPost("start")] + public async Task> StartRun() + { + var (run, room) = await _dungeonCrawlerService.StartRunAsync(CurrentPlayerId); + return Created($"api/dungeoncrawler/active", new { run, room }); + } + + /// Get the active dungeon run and current room. + [HttpGet("active")] + public async Task> GetActiveRun() + { + var result = await _dungeonCrawlerService.GetActiveRunAsync(CurrentPlayerId); + if (result == null) + return NotFound(new { message = "No active dungeon run." }); + + var (run, room) = result.Value; + return Ok(new { run, room }); + } + + /// Make a choice in the current room. + [HttpPost("{runId:guid}/action")] + public async Task> MakeChoice(Guid runId, [FromBody] DungeonActionRequest request) + { + var response = await _dungeonCrawlerService.MakeChoiceAsync(CurrentPlayerId, runId, request); + return Ok(response); + } + + /// Escape the dungeon with your collected loot. + [HttpPost("{runId:guid}/escape")] + public async Task> Escape(Guid runId) + { + var run = await _dungeonCrawlerService.EscapeAsync(CurrentPlayerId, runId); + return Ok(run); + } + + /// Get dungeon run history. + [HttpGet("history")] + public async Task>> GetHistory() + { + var runs = await _dungeonCrawlerService.GetRunHistoryAsync(CurrentPlayerId); + return Ok(runs); + } +} diff --git a/backend/WizardRPG.Api/Controllers/PotionBrewingController.cs b/backend/WizardRPG.Api/Controllers/PotionBrewingController.cs new file mode 100644 index 0000000..832d726 --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/PotionBrewingController.cs @@ -0,0 +1,55 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.PotionBrewing; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class PotionBrewingController : ControllerBase +{ + private readonly IPotionBrewingService _potionBrewingService; + + public PotionBrewingController(IPotionBrewingService potionBrewingService) => _potionBrewingService = potionBrewingService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get all potion recipes. + [HttpGet("recipes")] + [AllowAnonymous] + public async Task>> GetRecipes() + { + var recipes = await _potionBrewingService.GetRecipesAsync(); + return Ok(recipes); + } + + /// Get all available ingredients. + [HttpGet("ingredients")] + [AllowAnonymous] + public async Task>> GetIngredients() + { + var ingredients = await _potionBrewingService.GetIngredientsAsync(); + return Ok(ingredients); + } + + /// Attempt to brew a potion. + [HttpPost("brew")] + public async Task> BrewPotion([FromBody] BrewPotionRequest request) + { + var result = await _potionBrewingService.BrewPotionAsync(CurrentPlayerId, request); + return Created($"api/potionbrewing/history/{result.Id}", result); + } + + /// Get the current player's brew history. + [HttpGet("history")] + public async Task>> GetBrewHistory() + { + var history = await _potionBrewingService.GetPlayerBrewHistoryAsync(CurrentPlayerId); + return Ok(history); + } +} diff --git a/backend/WizardRPG.Api/Controllers/QuizController.cs b/backend/WizardRPG.Api/Controllers/QuizController.cs new file mode 100644 index 0000000..f63e8aa --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/QuizController.cs @@ -0,0 +1,58 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.Quiz; +using WizardRPG.Api.Models; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class QuizController : ControllerBase +{ + private readonly IQuizService _quizService; + + public QuizController(IQuizService quizService) => _quizService = quizService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get random quiz questions (correct answers not included). + [HttpGet("questions")] + [AllowAnonymous] + public async Task>> GetQuestions( + [FromQuery] int count = 5, + [FromQuery] QuizDifficulty? difficulty = null) + { + var questions = await _quizService.GetRandomQuestionsAsync(count, difficulty); + return Ok(questions); + } + + /// Submit quiz answers and receive results. + [HttpPost("submit")] + public async Task> SubmitQuiz([FromBody] SubmitQuizRequest request) + { + var result = await _quizService.SubmitQuizAsync(CurrentPlayerId, request); + return Ok(result); + } + + /// Get the current player's quiz history. + [HttpGet("history")] + public async Task>> GetHistory() + { + var history = await _quizService.GetQuizHistoryAsync(CurrentPlayerId); + return Ok(history); + } + + /// Get the quiz leaderboard. + [HttpGet("leaderboard")] + [AllowAnonymous] + public async Task>> GetLeaderboard([FromQuery] int top = 10) + { + var leaderboard = await _quizService.GetLeaderboardAsync(top); + return Ok(leaderboard); + } +} diff --git a/backend/WizardRPG.Api/Controllers/WhisperingWallsController.cs b/backend/WizardRPG.Api/Controllers/WhisperingWallsController.cs new file mode 100644 index 0000000..91c4f1f --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/WhisperingWallsController.cs @@ -0,0 +1,63 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.WhisperingWalls; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class WhisperingWallsController : ControllerBase +{ + private readonly IWhisperingWallsService _whisperingWallsService; + + public WhisperingWallsController(IWhisperingWallsService whisperingWallsService) => + _whisperingWallsService = whisperingWallsService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get all available story arcs. + [HttpGet("stories")] + [AllowAnonymous] + public async Task>> GetStoryArcs() + { + var arcs = await _whisperingWallsService.GetStoryArcsAsync(); + return Ok(arcs); + } + + /// Start or restart a story arc. + [HttpPost("start")] + public async Task> StartStory([FromQuery] string storyArc) + { + var chapter = await _whisperingWallsService.StartStoryAsync(CurrentPlayerId, storyArc); + return Ok(chapter); + } + + /// Get the current chapter for a story arc. + [HttpGet("current")] + public async Task> GetCurrentChapter([FromQuery] string storyArc) + { + var chapter = await _whisperingWallsService.GetCurrentChapterAsync(CurrentPlayerId, storyArc); + return Ok(chapter); + } + + /// Make a choice in the current chapter. + [HttpPost("choose")] + public async Task> MakeChoice([FromBody] MakeChoiceRequest request) + { + var result = await _whisperingWallsService.MakeChoiceAsync(CurrentPlayerId, request); + return Ok(result); + } + + /// Get all story progress for the current player. + [HttpGet("progress")] + public async Task>> GetProgress() + { + var progress = await _whisperingWallsService.GetProgressAsync(CurrentPlayerId); + return Ok(progress); + } +} diff --git a/backend/WizardRPG.Api/Controllers/WizardChessController.cs b/backend/WizardRPG.Api/Controllers/WizardChessController.cs new file mode 100644 index 0000000..dc06a18 --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/WizardChessController.cs @@ -0,0 +1,61 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.WizardChess; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class WizardChessController : ControllerBase +{ + private readonly IWizardChessService _chessService; + + public WizardChessController(IWizardChessService chessService) => _chessService = chessService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Create a new chess match. + [HttpPost("create")] + public async Task> CreateMatch([FromBody] CreateChessMatchRequest request) + { + var match = await _chessService.CreateMatchAsync(CurrentPlayerId, request); + return Created($"api/wizardchess/{match.Id}", match); + } + + /// Get a specific chess match. + [HttpGet("{matchId:guid}")] + public async Task> GetMatch(Guid matchId) + { + var match = await _chessService.GetMatchAsync(matchId); + return Ok(match); + } + + /// Get all matches for the current player. + [HttpGet("matches")] + public async Task>> GetMyMatches() + { + var matches = await _chessService.GetPlayerMatchesAsync(CurrentPlayerId); + return Ok(matches); + } + + /// Make a move in a chess match. + [HttpPost("{matchId:guid}/move")] + public async Task> MakeMove(Guid matchId, [FromBody] ChessMoveRequest request) + { + var result = await _chessService.MakeMoveAsync(CurrentPlayerId, matchId, request); + return Ok(result); + } + + /// Forfeit a chess match. + [HttpPost("{matchId:guid}/forfeit")] + public async Task> Forfeit(Guid matchId) + { + var match = await _chessService.ForfeitAsync(CurrentPlayerId, matchId); + return Ok(match); + } +} diff --git a/backend/WizardRPG.Api/DTOs/CreatureTaming/CreatureTamingDtos.cs b/backend/WizardRPG.Api/DTOs/CreatureTaming/CreatureTamingDtos.cs new file mode 100644 index 0000000..72bc9be --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/CreatureTaming/CreatureTamingDtos.cs @@ -0,0 +1,21 @@ +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.DTOs.CreatureTaming; + +public record CreatureResponse( + Guid Id, string Name, string Description, CreatureRarity Rarity, + int BaseHealth, int BaseAttack, string BonusType, int BonusValue); + +public record PlayerCreatureResponse( + Guid Id, Guid CreatureId, string CreatureName, string Description, + CreatureRarity Rarity, string Nickname, int Happiness, int Loyalty, + int Level, string BonusType, int BonusValue, + DateTime? LastFedAt, DateTime? LastTrainedAt, DateTime TamedAt); + +public record ExploreResponse(bool Found, CreatureResponse? Creature, string Narrative); + +public record TameCreatureRequest(Guid CreatureId, string? Nickname); + +public record CareForCreatureRequest(string Action); // "feed", "train", "rest" + +public record CareResponse(string Narrative, int HappinessChange, int LoyaltyChange, int? LevelUp); diff --git a/backend/WizardRPG.Api/DTOs/DungeonCrawler/DungeonCrawlerDtos.cs b/backend/WizardRPG.Api/DTOs/DungeonCrawler/DungeonCrawlerDtos.cs new file mode 100644 index 0000000..74734af --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/DungeonCrawler/DungeonCrawlerDtos.cs @@ -0,0 +1,20 @@ +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.DTOs.DungeonCrawler; + +public record DungeonRunResponse( + Guid Id, int CurrentFloor, int CurrentHp, int MaxHp, + long GoldCollected, int XpCollected, DungeonRunStatus Status, + DateTime StartedAt, DateTime? EndedAt); + +public record DungeonRoomResponse( + RoomType Type, string Description, List Choices); + +public record DungeonChoiceResponse(string Id, string Text, string RiskLevel); + +public record DungeonActionRequest(string ChoiceId); + +public record DungeonActionResponse( + string Narrative, int HpChange, long GoldChange, int XpChange, + int CurrentHp, long TotalGold, int TotalXp, bool RunEnded, + DungeonRoomResponse? NextRoom); diff --git a/backend/WizardRPG.Api/DTOs/PotionBrewing/PotionBrewingDtos.cs b/backend/WizardRPG.Api/DTOs/PotionBrewing/PotionBrewingDtos.cs new file mode 100644 index 0000000..2a62855 --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/PotionBrewing/PotionBrewingDtos.cs @@ -0,0 +1,22 @@ +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.DTOs.PotionBrewing; + +public record PotionRecipeResponse( + Guid Id, + string Name, + string Description, + int Difficulty, + int GoldReward, + int XpReward, + List Ingredients); + +public record RecipeIngredientResponse(Guid IngredientId, string IngredientName, int Quantity); + +public record PotionIngredientResponse(Guid Id, string Name, string Description, long Price); + +public record BrewAttemptResponse(Guid Id, Guid RecipeId, string RecipeName, BrewResult Result, DateTime CreatedAt); + +public record BrewPotionRequest(Guid RecipeId); + +public record BuyIngredientRequest(Guid IngredientId, int Quantity); diff --git a/backend/WizardRPG.Api/DTOs/Quiz/QuizDtos.cs b/backend/WizardRPG.Api/DTOs/Quiz/QuizDtos.cs new file mode 100644 index 0000000..6381f4d --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/Quiz/QuizDtos.cs @@ -0,0 +1,29 @@ +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.DTOs.Quiz; + +public record QuizQuestionResponse( + Guid Id, + string QuestionText, + string OptionA, + string OptionB, + string OptionC, + string OptionD, + QuizDifficulty Difficulty, + QuizCategory Category); + +public record SubmitQuizRequest(List Answers); +public record QuizAnswerRequest(Guid QuestionId, string SelectedOption); + +public record QuizResultResponse( + Guid Id, + int Score, + int TotalQuestions, + long GoldEarned, + int XpEarned, + DateTime CompletedAt, + List AnswerResults); + +public record QuizAnswerResult(Guid QuestionId, string SelectedOption, string CorrectOption, bool IsCorrect); + +public record QuizLeaderboardEntry(string Username, int TotalScore, int QuizzesCompleted); diff --git a/backend/WizardRPG.Api/DTOs/WhisperingWalls/WhisperingWallsDtos.cs b/backend/WizardRPG.Api/DTOs/WhisperingWalls/WhisperingWallsDtos.cs new file mode 100644 index 0000000..863b23b --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/WhisperingWalls/WhisperingWallsDtos.cs @@ -0,0 +1,22 @@ +namespace WizardRPG.Api.DTOs.WhisperingWalls; + +public record StoryChapterResponse( + Guid Id, string Title, string Content, string StoryArc, + bool IsEnding, int? GoldReward, int? XpReward, + List Choices); + +public record StoryChoiceResponse( + Guid Id, string ChoiceText, bool IsAvailable, string? RequirementHint); + +public record StoryArcResponse(string StoryArc, string FirstChapterTitle, int TotalChapters); + +public record MakeChoiceRequest(Guid ChoiceId); + +public record MakeChoiceResponse( + string Narrative, bool IsEnding, int? GoldEarned, int? XpEarned, + StoryChapterResponse? NextChapter); + +public record PlayerStoryProgressResponse( + Guid Id, string StoryArc, Guid CurrentChapterId, + string CurrentChapterTitle, bool IsCompleted, + DateTime StartedAt, DateTime? CompletedAt); diff --git a/backend/WizardRPG.Api/DTOs/WizardChess/WizardChessDtos.cs b/backend/WizardRPG.Api/DTOs/WizardChess/WizardChessDtos.cs new file mode 100644 index 0000000..0af760e --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/WizardChess/WizardChessDtos.cs @@ -0,0 +1,19 @@ +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.DTOs.WizardChess; + +public record ChessMatchResponse( + Guid Id, Guid ChallengerId, string ChallengerUsername, + Guid? DefenderId, string? DefenderUsername, + Guid? WinnerId, string? WinnerUsername, + ChessMatchStatus Status, long BetAmount, + string BoardState, bool IsPlayerTurn, int TurnCount, + DateTime CreatedAt, DateTime? FinishedAt); + +public record CreateChessMatchRequest(Guid? DefenderId, long BetAmount); + +public record ChessMoveRequest(int FromRow, int FromCol, int ToRow, int ToCol, bool UseAbility); + +public record ChessMoveResponse( + bool Valid, string Narrative, string BoardState, + bool IsPlayerTurn, bool GameOver, Guid? WinnerId, string? WinnerUsername); diff --git a/backend/WizardRPG.Api/Data/AppDbContext.cs b/backend/WizardRPG.Api/Data/AppDbContext.cs index f33435e..a203b39 100644 --- a/backend/WizardRPG.Api/Data/AppDbContext.cs +++ b/backend/WizardRPG.Api/Data/AppDbContext.cs @@ -19,6 +19,19 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Battles => Set(); public DbSet BattleTurns => Set(); public DbSet Spells => Set(); + public DbSet PotionRecipes => Set(); + public DbSet PotionIngredients => Set(); + public DbSet RecipeIngredients => Set(); + public DbSet BrewAttempts => Set(); + public DbSet QuizQuestions => Set(); + public DbSet QuizAttempts => Set(); + public DbSet DungeonRuns => Set(); + public DbSet Creatures => Set(); + public DbSet PlayerCreatures => Set(); + public DbSet StoryChapters => Set(); + public DbSet StoryChoices => Set(); + public DbSet PlayerStoryProgress => Set(); + public DbSet ChessMatches => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -151,5 +164,136 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(bt => bt.SpellId) .OnDelete(DeleteBehavior.Restrict); }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + }); + + modelBuilder.Entity(e => + { + e.HasKey(i => i.Id); + e.Property(i => i.Price).HasDefaultValue(10L); + }); + + modelBuilder.Entity(e => + { + e.HasKey(ri => ri.Id); + e.HasOne(ri => ri.Recipe) + .WithMany(r => r.Ingredients) + .HasForeignKey(ri => ri.RecipeId) + .OnDelete(DeleteBehavior.Cascade); + e.HasOne(ri => ri.Ingredient) + .WithMany(i => i.RecipeIngredients) + .HasForeignKey(ri => ri.IngredientId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.HasKey(a => a.Id); + e.HasOne(a => a.Player) + .WithMany(p => p.BrewAttempts) + .HasForeignKey(a => a.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + e.HasOne(a => a.Recipe) + .WithMany(r => r.BrewAttempts) + .HasForeignKey(a => a.RecipeId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(e => + { + e.HasKey(q => q.Id); + }); + + modelBuilder.Entity(e => + { + e.HasKey(a => a.Id); + e.HasOne(a => a.Player) + .WithMany(p => p.QuizAttempts) + .HasForeignKey(a => a.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + e.HasOne(r => r.Player) + .WithMany(p => p.DungeonRuns) + .HasForeignKey(r => r.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + e.Property(r => r.GoldCollected).HasDefaultValue(0L); + e.Property(r => r.CurrentFloor).HasDefaultValue(1); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + }); + + modelBuilder.Entity(e => + { + e.HasKey(pc => pc.Id); + e.HasOne(pc => pc.Player) + .WithMany(p => p.PlayerCreatures) + .HasForeignKey(pc => pc.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + e.HasOne(pc => pc.Creature) + .WithMany(c => c.PlayerCreatures) + .HasForeignKey(pc => pc.CreatureId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + e.HasOne(c => c.Chapter) + .WithMany(ch => ch.Choices) + .HasForeignKey(c => c.ChapterId) + .OnDelete(DeleteBehavior.Cascade); + e.HasOne(c => c.NextChapter) + .WithMany() + .HasForeignKey(c => c.NextChapterId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + }); + + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + e.HasOne(p => p.Player) + .WithMany(pl => pl.StoryProgress) + .HasForeignKey(p => p.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + e.HasOne(p => p.CurrentChapter) + .WithMany() + .HasForeignKey(p => p.CurrentChapterId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(e => + { + e.HasKey(m => m.Id); + e.HasOne(m => m.Challenger) + .WithMany() + .HasForeignKey(m => m.ChallengerId) + .OnDelete(DeleteBehavior.Restrict); + e.HasOne(m => m.Defender) + .WithMany() + .HasForeignKey(m => m.DefenderId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + e.HasOne(m => m.Winner) + .WithMany() + .HasForeignKey(m => m.WinnerId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + }); } } diff --git a/backend/WizardRPG.Api/Data/SeedData.cs b/backend/WizardRPG.Api/Data/SeedData.cs index ee33cdd..49b1b56 100644 --- a/backend/WizardRPG.Api/Data/SeedData.cs +++ b/backend/WizardRPG.Api/Data/SeedData.cs @@ -33,5 +33,250 @@ public static void Seed(AppDbContext context) context.Spells.AddRange(spells); context.Items.AddRange(items); context.SaveChanges(); + + if (!context.PotionIngredients.Any()) + { + var dragonScale = new PotionIngredient { Id = Guid.NewGuid(), Name = "Dragon Scale", Description = "A shimmering scale shed by an ancient dragon.", Price = 50 }; + var moonstoneDust = new PotionIngredient { Id = Guid.NewGuid(), Name = "Moonstone Dust", Description = "Fine powder from a moonstone, glows faintly at night.", Price = 30 }; + var phoenixFeather = new PotionIngredient { Id = Guid.NewGuid(), Name = "Phoenix Feather", Description = "A radiant feather that is warm to the touch.", Price = 80 }; + var toadstoolCap = new PotionIngredient { Id = Guid.NewGuid(), Name = "Toadstool Cap", Description = "A spotted mushroom cap with mild magical properties.", Price = 15 }; + var fairyWing = new PotionIngredient { Id = Guid.NewGuid(), Name = "Fairy Wing", Description = "A delicate wing donated by a friendly fairy.", Price = 40 }; + var mandrakeRoot = new PotionIngredient { Id = Guid.NewGuid(), Name = "Mandrake Root", Description = "A gnarled root that screams when pulled from the earth.", Price = 25 }; + + context.PotionIngredients.AddRange(dragonScale, moonstoneDust, phoenixFeather, toadstoolCap, fairyWing, mandrakeRoot); + + var healingBrew = new PotionRecipe + { + Id = Guid.NewGuid(), + Name = "Healing Brew", + Description = "A soothing potion that mends minor wounds.", + Difficulty = 1, + GoldReward = 20, + XpReward = 15 + }; + + var invisibilityDraught = new PotionRecipe + { + Id = Guid.NewGuid(), + Name = "Invisibility Draught", + Description = "Turns the drinker invisible for a short time.", + Difficulty = 3, + GoldReward = 60, + XpReward = 40 + }; + + var fireResistanceElixir = new PotionRecipe + { + Id = Guid.NewGuid(), + Name = "Fire Resistance Elixir", + Description = "Grants temporary immunity to fire damage.", + Difficulty = 4, + GoldReward = 100, + XpReward = 65 + }; + + var felixFelicis = new PotionRecipe + { + Id = Guid.NewGuid(), + Name = "Felix Felicis", + Description = "Liquid luck — everything seems to go right for a while.", + Difficulty = 5, + GoldReward = 200, + XpReward = 100 + }; + + context.PotionRecipes.AddRange(healingBrew, invisibilityDraught, fireResistanceElixir, felixFelicis); + context.SaveChanges(); + + var recipeIngredients = new List + { + // Healing Brew: 2 Toadstool Cap + 1 Mandrake Root + new() { RecipeId = healingBrew.Id, IngredientId = toadstoolCap.Id, Quantity = 2 }, + new() { RecipeId = healingBrew.Id, IngredientId = mandrakeRoot.Id, Quantity = 1 }, + // Invisibility Draught: 1 Moonstone Dust + 2 Fairy Wing + new() { RecipeId = invisibilityDraught.Id, IngredientId = moonstoneDust.Id, Quantity = 1 }, + new() { RecipeId = invisibilityDraught.Id, IngredientId = fairyWing.Id, Quantity = 2 }, + // Fire Resistance Elixir: 2 Dragon Scale + 1 Phoenix Feather + new() { RecipeId = fireResistanceElixir.Id, IngredientId = dragonScale.Id, Quantity = 2 }, + new() { RecipeId = fireResistanceElixir.Id, IngredientId = phoenixFeather.Id, Quantity = 1 }, + // Felix Felicis: 1 Phoenix Feather + 1 Moonstone Dust + 1 Fairy Wing + 1 Dragon Scale + new() { RecipeId = felixFelicis.Id, IngredientId = phoenixFeather.Id, Quantity = 1 }, + new() { RecipeId = felixFelicis.Id, IngredientId = moonstoneDust.Id, Quantity = 1 }, + new() { RecipeId = felixFelicis.Id, IngredientId = fairyWing.Id, Quantity = 1 }, + new() { RecipeId = felixFelicis.Id, IngredientId = dragonScale.Id, Quantity = 1 }, + }; + + context.RecipeIngredients.AddRange(recipeIngredients); + context.SaveChanges(); + } + + if (!context.QuizQuestions.Any()) + { + var quizQuestions = new List + { + // SpellLore - Easy + new() { QuestionText = "Which element is associated with the Fireball spell?", OptionA = "Ice", OptionB = "Fire", OptionC = "Lightning", OptionD = "Earth", CorrectOption = "B", Difficulty = QuizDifficulty.Easy, Category = QuizCategory.SpellLore }, + // SpellLore - Medium + new() { QuestionText = "What is the mana cost of the Thunder Strike spell?", OptionA = "25", OptionB = "30", OptionC = "35", OptionD = "40", CorrectOption = "C", Difficulty = QuizDifficulty.Medium, Category = QuizCategory.SpellLore }, + // SpellLore - Hard + new() { QuestionText = "Which spell has the highest base damage and costs 50 mana?", OptionA = "Arcane Blast", OptionB = "Lava Surge", OptionC = "Earthquake", OptionD = "Fireball", CorrectOption = "C", Difficulty = QuizDifficulty.Hard, Category = QuizCategory.SpellLore }, + + // MagicalCreatures - Easy + new() { QuestionText = "Which magical creature is reborn from its own ashes?", OptionA = "Dragon", OptionB = "Unicorn", OptionC = "Phoenix", OptionD = "Griffin", CorrectOption = "C", Difficulty = QuizDifficulty.Easy, Category = QuizCategory.MagicalCreatures }, + // MagicalCreatures - Medium + new() { QuestionText = "What is a Dragon's most prized possession used in potion brewing?", OptionA = "Dragon Claw", OptionB = "Dragon Scale", OptionC = "Dragon Tooth", OptionD = "Dragon Eye", CorrectOption = "B", Difficulty = QuizDifficulty.Medium, Category = QuizCategory.MagicalCreatures }, + // MagicalCreatures - Hard + new() { QuestionText = "Which creature's feather is described as warm to the touch and costs 80 gold?", OptionA = "Griffin", OptionB = "Hippogriff", OptionC = "Phoenix", OptionD = "Thunderbird", CorrectOption = "C", Difficulty = QuizDifficulty.Hard, Category = QuizCategory.MagicalCreatures }, + + // PotionIngredients - Easy + new() { QuestionText = "Which ingredient glows faintly at night?", OptionA = "Dragon Scale", OptionB = "Mandrake Root", OptionC = "Moonstone Dust", OptionD = "Toadstool Cap", CorrectOption = "C", Difficulty = QuizDifficulty.Easy, Category = QuizCategory.PotionIngredients }, + // PotionIngredients - Medium + new() { QuestionText = "What happens when you pull a Mandrake Root from the earth?", OptionA = "It glows", OptionB = "It screams", OptionC = "It explodes", OptionD = "It vanishes", CorrectOption = "B", Difficulty = QuizDifficulty.Medium, Category = QuizCategory.PotionIngredients }, + // PotionIngredients - Hard + new() { QuestionText = "Which two ingredients are needed to brew the Fire Resistance Elixir?", OptionA = "Moonstone Dust and Fairy Wing", OptionB = "Toadstool Cap and Mandrake Root", OptionC = "Dragon Scale and Phoenix Feather", OptionD = "Fairy Wing and Dragon Scale", CorrectOption = "C", Difficulty = QuizDifficulty.Hard, Category = QuizCategory.PotionIngredients }, + + // WizardHistory - Easy + new() { QuestionText = "What is the name of the academy where young wizards first learn to cast spells?", OptionA = "The Arcane Academy", OptionB = "The Crystal Tower", OptionC = "The Shadow Keep", OptionD = "The Ember Sanctum", CorrectOption = "A", Difficulty = QuizDifficulty.Easy, Category = QuizCategory.WizardHistory }, + // WizardHistory - Medium + new() { QuestionText = "Who is credited with creating the first Arcane Blast spell?", OptionA = "Thalindra the Wise", OptionB = "Eldric Stormweaver", OptionC = "Meraxis the Bold", OptionD = "Solara Dawnfire", CorrectOption = "B", Difficulty = QuizDifficulty.Medium, Category = QuizCategory.WizardHistory }, + // WizardHistory - Hard + new() { QuestionText = "During the Great Mage War, which school of magic was temporarily forbidden?", OptionA = "Fire Magic", OptionB = "Ice Magic", OptionC = "Arcane Magic", OptionD = "Earth Magic", CorrectOption = "C", Difficulty = QuizDifficulty.Hard, Category = QuizCategory.WizardHistory }, + }; + + context.QuizQuestions.AddRange(quizQuestions); + context.SaveChanges(); + } + + if (!context.Creatures.Any()) + { + var creatures = new List + { + // Common + new() { Id = Guid.NewGuid(), Name = "Fire Sprite", Description = "A tiny, mischievous flame spirit that hoards shiny coins.", Rarity = CreatureRarity.Common, BaseHealth = 30, BaseAttack = 8, BonusType = "gold", BonusValue = 5 }, + new() { Id = Guid.NewGuid(), Name = "Shadow Cat", Description = "A sleek feline that moves between shadows with uncanny speed.", Rarity = CreatureRarity.Common, BaseHealth = 35, BaseAttack = 12, BonusType = "speed", BonusValue = 5 }, + // Uncommon + new() { Id = Guid.NewGuid(), Name = "Storm Hawk", Description = "A majestic bird of prey that channels lightning through its feathers.", Rarity = CreatureRarity.Uncommon, BaseHealth = 50, BaseAttack = 18, BonusType = "magic", BonusValue = 8 }, + new() { Id = Guid.NewGuid(), Name = "Iron Golem", Description = "A small but sturdy construct of enchanted metal that never tires.", Rarity = CreatureRarity.Uncommon, BaseHealth = 80, BaseAttack = 15, BonusType = "strength", BonusValue = 8 }, + // Rare + new() { Id = Guid.NewGuid(), Name = "Phoenix Hatchling", Description = "A baby phoenix wreathed in golden flames, radiating arcane energy and ancient knowledge.", Rarity = CreatureRarity.Rare, BaseHealth = 60, BaseAttack = 25, BonusType = "magic+wisdom", BonusValue = 12 }, + new() { Id = Guid.NewGuid(), Name = "Frost Dragon", Description = "A young dragon of ice that combines raw power with blinding agility.", Rarity = CreatureRarity.Rare, BaseHealth = 90, BaseAttack = 30, BonusType = "strength+speed", BonusValue = 12 }, + // Legendary + new() { Id = Guid.NewGuid(), Name = "Ancient Basilisk", Description = "A primordial serpent whose gaze petrifies and whose presence empowers all aspects of a wizard.", Rarity = CreatureRarity.Legendary, BaseHealth = 120, BaseAttack = 40, BonusType = "gold+magic+strength+wisdom+speed", BonusValue = 15 }, + new() { Id = Guid.NewGuid(), Name = "Celestial Unicorn", Description = "A divine equine that blesses its companion with fortune and profound insight.", Rarity = CreatureRarity.Legendary, BaseHealth = 100, BaseAttack = 35, BonusType = "gold+wisdom", BonusValue = 20 }, + }; + + context.Creatures.AddRange(creatures); + context.SaveChanges(); + } + + if (!context.StoryChapters.Any()) + { + // Chapter 1: The Discovery + var ch1 = new StoryChapter + { + Id = Guid.NewGuid(), + Title = "The Discovery", + Content = "While wandering the castle's lower halls, you notice a faint shimmer behind a tapestry. Pulling it aside reveals a narrow corridor you've never seen before. Cold air seeps from the darkness within, carrying whispers too faint to understand. The stones around the entrance are etched with ancient runes that pulse with a dim violet light.", + StoryArc = "The Forbidden Corridor", + OrderIndex = 0, + IsEnding = false + }; + + // Chapter 2: The Guardian + var ch2 = new StoryChapter + { + Id = Guid.NewGuid(), + Title = "The Guardian", + Content = "You step into the corridor and the tapestry falls shut behind you. Torches flicker to life along the walls, illuminating a massive stone golem blocking the passage ahead. Its hollow eyes glow with an amber light, and a deep rumble echoes as it speaks: 'None shall pass without proving their worth.' The ground trembles beneath your feet.", + StoryArc = "The Forbidden Corridor", + OrderIndex = 1, + IsEnding = false + }; + + // Chapter 3: The Secret Chamber + var ch3 = new StoryChapter + { + Id = Guid.NewGuid(), + Title = "The Secret Chamber", + Content = "Beyond the golem lies a vast underground chamber filled with towering bookshelves and floating candles. An ancient library, untouched for centuries. Dust motes dance in the candlelight. On a central pedestal sit two objects: a leather-bound tome radiating gentle warmth, and a crystalline artifact pulsing with raw magical energy. You sense you can only take one.", + StoryArc = "The Forbidden Corridor", + OrderIndex = 2, + IsEnding = false + }; + + // Chapter 4: Knowledge Gained (ending) + var ch4 = new StoryChapter + { + Id = Guid.NewGuid(), + Title = "Knowledge Gained", + Content = "You open the ancient tome and knowledge floods your mind — centuries of forgotten spells, lost histories, and arcane secrets. The whispers in the walls grow clear, thanking you for choosing wisdom over power. As you leave the corridor, you feel fundamentally changed. The runes on the entrance dim and seal shut, but the knowledge remains yours forever.", + StoryArc = "The Forbidden Corridor", + OrderIndex = 3, + IsEnding = true, + GoldReward = 50, + XpReward = 100 + }; + + // Chapter 5: The Artifact's Curse (ending) + var ch5 = new StoryChapter + { + Id = Guid.NewGuid(), + Title = "The Artifact's Curse", + Content = "You grasp the crystalline artifact and power surges through you. The whispers turn to screams, then silence. The artifact crumbles into golden dust that seeps into your skin, granting you immense but volatile power. As you exit the corridor, you notice your reflection has changed — your eyes now carry an eerie glow. Great power, but at what cost?", + StoryArc = "The Forbidden Corridor", + OrderIndex = 4, + IsEnding = true, + GoldReward = 100, + XpReward = 50 + }; + + // Ending: Walk Away + var chWalkAway = new StoryChapter + { + Id = Guid.NewGuid(), + Title = "Discretion is Valor", + Content = "You let the tapestry fall back into place and walk away. Some secrets are best left undiscovered. The whispers fade behind you, and by morning you almost convince yourself it was just the wind. But sometimes, late at night, you still hear them calling...", + StoryArc = "The Forbidden Corridor", + OrderIndex = 5, + IsEnding = true, + GoldReward = 10, + XpReward = 10 + }; + + // Ending: Run from golem + var chRun = new StoryChapter + { + Id = Guid.NewGuid(), + Title = "A Hasty Retreat", + Content = "The golem is too imposing. You turn and sprint back through the corridor as the walls begin to shake. You burst through the tapestry and collapse in the hallway, heart pounding. When you look back, the corridor is gone — only solid stone remains. Perhaps you'll be braver next time.", + StoryArc = "The Forbidden Corridor", + OrderIndex = 6, + IsEnding = true, + GoldReward = 5, + XpReward = 15 + }; + + context.StoryChapters.AddRange(ch1, ch2, ch3, ch4, ch5, chWalkAway, chRun); + context.SaveChanges(); + + var choices = new List + { + // Chapter 1 choices + new() { Id = Guid.NewGuid(), ChapterId = ch1.Id, ChoiceText = "Enter the forbidden corridor", NextChapterId = ch2.Id }, + new() { Id = Guid.NewGuid(), ChapterId = ch1.Id, ChoiceText = "Walk away — some doors are best left closed", NextChapterId = chWalkAway.Id }, + + // Chapter 2 choices + new() { Id = Guid.NewGuid(), ChapterId = ch2.Id, ChoiceText = "Channel your wisdom to outwit the golem", NextChapterId = ch3.Id, MinWisdom = 15 }, + new() { Id = Guid.NewGuid(), ChapterId = ch2.Id, ChoiceText = "Negotiate with the ancient guardian", NextChapterId = ch3.Id }, + new() { Id = Guid.NewGuid(), ChapterId = ch2.Id, ChoiceText = "Run back the way you came", NextChapterId = chRun.Id }, + + // Chapter 3 choices + new() { Id = Guid.NewGuid(), ChapterId = ch3.Id, ChoiceText = "Read the ancient tome", NextChapterId = ch4.Id }, + new() { Id = Guid.NewGuid(), ChapterId = ch3.Id, ChoiceText = "Take the crystalline artifact", NextChapterId = ch5.Id }, + }; + + context.StoryChoices.AddRange(choices); + context.SaveChanges(); + } } } diff --git a/backend/WizardRPG.Api/Models/CreatureTaming.cs b/backend/WizardRPG.Api/Models/CreatureTaming.cs new file mode 100644 index 0000000..1113d6f --- /dev/null +++ b/backend/WizardRPG.Api/Models/CreatureTaming.cs @@ -0,0 +1,40 @@ +namespace WizardRPG.Api.Models; + +public enum CreatureRarity +{ + Common, + Uncommon, + Rare, + Legendary +} + +public class Creature +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public CreatureRarity Rarity { get; set; } + public int BaseHealth { get; set; } = 50; + public int BaseAttack { get; set; } = 10; + public string BonusType { get; set; } = string.Empty; // "gold", "magic", "strength", "wisdom", "speed" + public int BonusValue { get; set; } = 0; + + public ICollection PlayerCreatures { get; set; } = new List(); +} + +public class PlayerCreature +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlayerId { get; set; } + public Guid CreatureId { get; set; } + public string Nickname { get; set; } = string.Empty; + public int Happiness { get; set; } = 50; // 0-100 + public int Loyalty { get; set; } = 0; // 0-100 + public int Level { get; set; } = 1; + public DateTime? LastFedAt { get; set; } + public DateTime? LastTrainedAt { get; set; } + public DateTime TamedAt { get; set; } = DateTime.UtcNow; + + public Player? Player { get; set; } + public Creature? Creature { get; set; } +} diff --git a/backend/WizardRPG.Api/Models/DungeonCrawler.cs b/backend/WizardRPG.Api/Models/DungeonCrawler.cs new file mode 100644 index 0000000..fd40a11 --- /dev/null +++ b/backend/WizardRPG.Api/Models/DungeonCrawler.cs @@ -0,0 +1,34 @@ +namespace WizardRPG.Api.Models; + +public enum DungeonRunStatus +{ + Active, + Escaped, + Defeated +} + +public enum RoomType +{ + Monster, + Treasure, + Trap, + Merchant, + Rest, + Boss +} + +public class DungeonRun +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlayerId { get; set; } + public int CurrentFloor { get; set; } = 1; + public int CurrentHp { get; set; } = 100; + public int MaxHp { get; set; } = 100; + public long GoldCollected { get; set; } = 0; + public int XpCollected { get; set; } = 0; + public DungeonRunStatus Status { get; set; } = DungeonRunStatus.Active; + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + public DateTime? EndedAt { get; set; } + + public Player? Player { get; set; } +} diff --git a/backend/WizardRPG.Api/Models/Player.cs b/backend/WizardRPG.Api/Models/Player.cs index 06aee8d..7ed8501 100644 --- a/backend/WizardRPG.Api/Models/Player.cs +++ b/backend/WizardRPG.Api/Models/Player.cs @@ -23,4 +23,9 @@ public class Player public ICollection BankItems { get; set; } = new List(); public ICollection BroomBets { get; set; } = new List(); public ICollection FellowshipMemberships { get; set; } = new List(); + public ICollection BrewAttempts { get; set; } = new List(); + public ICollection QuizAttempts { get; set; } = new List(); + public ICollection DungeonRuns { get; set; } = new List(); + public ICollection PlayerCreatures { get; set; } = new List(); + public ICollection StoryProgress { get; set; } = new List(); } diff --git a/backend/WizardRPG.Api/Models/PotionBrewing.cs b/backend/WizardRPG.Api/Models/PotionBrewing.cs new file mode 100644 index 0000000..6fc0677 --- /dev/null +++ b/backend/WizardRPG.Api/Models/PotionBrewing.cs @@ -0,0 +1,54 @@ +namespace WizardRPG.Api.Models; + +public enum BrewResult +{ + Success, + Failure, + Explosion +} + +public class PotionRecipe +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int Difficulty { get; set; } = 1; // 1-5 + public int GoldReward { get; set; } = 0; + public int XpReward { get; set; } = 0; + + public ICollection Ingredients { get; set; } = new List(); + public ICollection BrewAttempts { get; set; } = new List(); +} + +public class PotionIngredient +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public long Price { get; set; } = 10; + + public ICollection RecipeIngredients { get; set; } = new List(); +} + +public class RecipeIngredient +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid RecipeId { get; set; } + public Guid IngredientId { get; set; } + public int Quantity { get; set; } = 1; + + public PotionRecipe? Recipe { get; set; } + public PotionIngredient? Ingredient { get; set; } +} + +public class BrewAttempt +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlayerId { get; set; } + public Guid RecipeId { get; set; } + public BrewResult Result { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public Player? Player { get; set; } + public PotionRecipe? Recipe { get; set; } +} diff --git a/backend/WizardRPG.Api/Models/Quiz.cs b/backend/WizardRPG.Api/Models/Quiz.cs new file mode 100644 index 0000000..dd1e7cc --- /dev/null +++ b/backend/WizardRPG.Api/Models/Quiz.cs @@ -0,0 +1,42 @@ +namespace WizardRPG.Api.Models; + +public enum QuizDifficulty +{ + Easy, + Medium, + Hard +} + +public enum QuizCategory +{ + SpellLore, + MagicalCreatures, + PotionIngredients, + WizardHistory +} + +public class QuizQuestion +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string QuestionText { get; set; } = string.Empty; + public string OptionA { get; set; } = string.Empty; + public string OptionB { get; set; } = string.Empty; + public string OptionC { get; set; } = string.Empty; + public string OptionD { get; set; } = string.Empty; + public string CorrectOption { get; set; } = string.Empty; // "A", "B", "C", or "D" + public QuizDifficulty Difficulty { get; set; } + public QuizCategory Category { get; set; } +} + +public class QuizAttempt +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlayerId { get; set; } + public int Score { get; set; } + public int TotalQuestions { get; set; } + public long GoldEarned { get; set; } + public int XpEarned { get; set; } + public DateTime CompletedAt { get; set; } = DateTime.UtcNow; + + public Player? Player { get; set; } +} diff --git a/backend/WizardRPG.Api/Models/WhisperingWalls.cs b/backend/WizardRPG.Api/Models/WhisperingWalls.cs new file mode 100644 index 0000000..c8b20f3 --- /dev/null +++ b/backend/WizardRPG.Api/Models/WhisperingWalls.cs @@ -0,0 +1,42 @@ +namespace WizardRPG.Api.Models; + +public class StoryChapter +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string StoryArc { get; set; } = string.Empty; + public int OrderIndex { get; set; } = 0; + public bool IsEnding { get; set; } = false; + public int? GoldReward { get; set; } + public int? XpReward { get; set; } + + public ICollection Choices { get; set; } = new List(); +} + +public class StoryChoice +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChapterId { get; set; } + public string ChoiceText { get; set; } = string.Empty; + public Guid? NextChapterId { get; set; } + public string? RequiredItemName { get; set; } + public int? MinWisdom { get; set; } + + public StoryChapter? Chapter { get; set; } + public StoryChapter? NextChapter { get; set; } +} + +public class PlayerStoryProgress +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlayerId { get; set; } + public Guid CurrentChapterId { get; set; } + public string StoryArc { get; set; } = string.Empty; + public bool IsCompleted { get; set; } = false; + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } + + public Player? Player { get; set; } + public StoryChapter? CurrentChapter { get; set; } +} diff --git a/backend/WizardRPG.Api/Models/WizardChess.cs b/backend/WizardRPG.Api/Models/WizardChess.cs new file mode 100644 index 0000000..bcb49b4 --- /dev/null +++ b/backend/WizardRPG.Api/Models/WizardChess.cs @@ -0,0 +1,28 @@ +namespace WizardRPG.Api.Models; + +public enum ChessMatchStatus +{ + Pending, + Active, + Finished, + Forfeit +} + +public class ChessMatch +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChallengerId { get; set; } + public Guid? DefenderId { get; set; } + public Guid? WinnerId { get; set; } + public ChessMatchStatus Status { get; set; } = ChessMatchStatus.Pending; + public long BetAmount { get; set; } = 0; + public string BoardState { get; set; } = string.Empty; + public bool IsPlayerTurn { get; set; } = true; + public int TurnCount { get; set; } = 0; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? FinishedAt { get; set; } + + public Player? Challenger { get; set; } + public Player? Defender { get; set; } + public Player? Winner { get; set; } +} diff --git a/backend/WizardRPG.Api/Program.cs b/backend/WizardRPG.Api/Program.cs index f7abecb..4cb6664 100644 --- a/backend/WizardRPG.Api/Program.cs +++ b/backend/WizardRPG.Api/Program.cs @@ -55,9 +55,15 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); // SignalR diff --git a/backend/WizardRPG.Api/Services/CreatureTamingService.cs b/backend/WizardRPG.Api/Services/CreatureTamingService.cs new file mode 100644 index 0000000..1462cc4 --- /dev/null +++ b/backend/WizardRPG.Api/Services/CreatureTamingService.cs @@ -0,0 +1,242 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.CreatureTaming; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface ICreatureTamingService +{ + Task> GetAllCreaturesAsync(); + Task> GetPlayerCreaturesAsync(Guid playerId); + Task ExploreAsync(Guid playerId); + Task TameCreatureAsync(Guid playerId, TameCreatureRequest request); + Task CareForCreatureAsync(Guid playerId, Guid creatureId, CareForCreatureRequest request); + Task> GetCreatureBonusesAsync(Guid playerId); +} + +public class CreatureTamingService : ICreatureTamingService +{ + private readonly AppDbContext _db; + + public CreatureTamingService(AppDbContext db) => _db = db; + + public async Task> GetAllCreaturesAsync() + { + var creatures = await _db.Creatures.OrderBy(c => c.Rarity).ThenBy(c => c.Name).ToListAsync(); + return creatures.Select(MapCreatureToResponse).ToList(); + } + + public async Task> GetPlayerCreaturesAsync(Guid playerId) + { + var playerCreatures = await _db.PlayerCreatures + .Include(pc => pc.Creature) + .Where(pc => pc.PlayerId == playerId) + .OrderByDescending(pc => pc.TamedAt) + .ToListAsync(); + + return playerCreatures.Select(MapPlayerCreatureToResponse).ToList(); + } + + public async Task ExploreAsync(Guid playerId) + { + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + const long exploreCost = 50; + if (player.GoldCoins < exploreCost) + throw new InvalidOperationException("Not enough gold to explore. You need 50 gold coins."); + + player.GoldCoins -= exploreCost; + + var roll = Random.Shared.Next(100); + CreatureRarity? foundRarity = roll switch + { + < 5 => CreatureRarity.Legendary, + < 20 => CreatureRarity.Rare, + < 50 => CreatureRarity.Uncommon, + _ => CreatureRarity.Common + }; + + var creatures = await _db.Creatures.Where(c => c.Rarity == foundRarity).ToListAsync(); + if (creatures.Count == 0) + { + await _db.SaveChangesAsync(); + return new ExploreResponse(false, null, + "You ventured deep into the enchanted forest but found nothing this time. The magical creatures remain elusive..."); + } + + var found = creatures[Random.Shared.Next(creatures.Count)]; + await _db.SaveChangesAsync(); + + var narrative = foundRarity switch + { + CreatureRarity.Legendary => $"Incredible! A mythical {found.Name} appears before you, radiating ancient power! This is a once-in-a-lifetime encounter!", + CreatureRarity.Rare => $"Amazing! You spot a rare {found.Name} hiding among the magical flora. It watches you cautiously...", + CreatureRarity.Uncommon => $"You discover a {found.Name} resting by a crystal stream. It seems curious about you.", + _ => $"A wild {found.Name} emerges from the underbrush, playfully circling around you." + }; + + return new ExploreResponse(true, MapCreatureToResponse(found), narrative); + } + + public async Task TameCreatureAsync(Guid playerId, TameCreatureRequest request) + { + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + var creature = await _db.Creatures.FindAsync(request.CreatureId) + ?? throw new KeyNotFoundException("Creature not found."); + + var playerCreature = new PlayerCreature + { + PlayerId = playerId, + CreatureId = request.CreatureId, + Nickname = string.IsNullOrWhiteSpace(request.Nickname) ? creature.Name : request.Nickname, + Happiness = 50, + Loyalty = 0, + Level = 1, + TamedAt = DateTime.UtcNow + }; + + _db.PlayerCreatures.Add(playerCreature); + await _db.SaveChangesAsync(); + + playerCreature.Creature = creature; + return MapPlayerCreatureToResponse(playerCreature); + } + + public async Task CareForCreatureAsync(Guid playerId, Guid creatureId, CareForCreatureRequest request) + { + var playerCreature = await _db.PlayerCreatures + .Include(pc => pc.Creature) + .FirstOrDefaultAsync(pc => pc.Id == creatureId && pc.PlayerId == playerId) + ?? throw new KeyNotFoundException("Creature not found in your collection."); + + var now = DateTime.UtcNow; + var action = request.Action.ToLowerInvariant(); + + int happinessChange = 0; + int loyaltyChange = 0; + int? levelUp = null; + string narrative; + + switch (action) + { + case "feed": + if (playerCreature.LastFedAt.HasValue && + (now - playerCreature.LastFedAt.Value).TotalHours < 1) + { + throw new InvalidOperationException( + "Your creature was fed recently. Please wait before feeding again."); + } + + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + const long feedCost = 20; + if (player.GoldCoins < feedCost) + throw new InvalidOperationException("Not enough gold to feed your creature. You need 20 gold coins."); + + player.GoldCoins -= feedCost; + happinessChange = 10; + playerCreature.Happiness = Math.Min(100, playerCreature.Happiness + happinessChange); + playerCreature.LastFedAt = now; + narrative = $"You feed {playerCreature.Nickname} a delicious magical treat. It purrs with delight!"; + break; + + case "train": + if (playerCreature.LastTrainedAt.HasValue && + (now - playerCreature.LastTrainedAt.Value).TotalHours < 2) + { + throw new InvalidOperationException( + "Your creature is still tired from the last training session. Please wait before training again."); + } + + loyaltyChange = 5; + playerCreature.Loyalty = Math.Min(100, playerCreature.Loyalty + loyaltyChange); + playerCreature.LastTrainedAt = now; + + // Check level up thresholds: 25, 50, 75, 100 + int newLevel = playerCreature.Loyalty switch + { + >= 100 => 5, + >= 75 => 4, + >= 50 => 3, + >= 25 => 2, + _ => 1 + }; + if (newLevel > playerCreature.Level) + { + playerCreature.Level = newLevel; + levelUp = newLevel; + } + + narrative = levelUp.HasValue + ? $"Excellent training session! {playerCreature.Nickname} has grown stronger and reached level {levelUp}!" + : $"You train with {playerCreature.Nickname}. Your bond grows stronger through the exercise."; + break; + + case "rest": + happinessChange = 15; + loyaltyChange = playerCreature.Loyalty < 20 ? -5 : 0; + playerCreature.Happiness = Math.Min(100, playerCreature.Happiness + happinessChange); + playerCreature.Loyalty = Math.Max(0, playerCreature.Loyalty + loyaltyChange); + narrative = loyaltyChange < 0 + ? $"{playerCreature.Nickname} rests peacefully but seems a bit distant. Spend more time training together!" + : $"{playerCreature.Nickname} curls up for a restful nap. Its mood visibly brightens."; + break; + + default: + throw new ArgumentException("Invalid action. Use 'feed', 'train', or 'rest'."); + } + + await _db.SaveChangesAsync(); + return new CareResponse(narrative, happinessChange, loyaltyChange, levelUp); + } + + public async Task> GetCreatureBonusesAsync(Guid playerId) + { + var loyalCreatures = await _db.PlayerCreatures + .Include(pc => pc.Creature) + .Where(pc => pc.PlayerId == playerId && pc.Loyalty > 50) + .ToListAsync(); + + var bonuses = new Dictionary + { + ["gold"] = 0, + ["magic"] = 0, + ["strength"] = 0, + ["wisdom"] = 0, + ["speed"] = 0 + }; + + foreach (var pc in loyalCreatures) + { + if (pc.Creature == null) continue; + + // Creatures with combined bonus types (e.g., "magic+wisdom") + var bonusTypes = pc.Creature.BonusType.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var bonusType in bonusTypes) + { + var key = bonusType.ToLowerInvariant(); + if (bonuses.ContainsKey(key)) + { + bonuses[key] += pc.Creature.BonusValue * pc.Level; + } + } + } + + return bonuses; + } + + private static CreatureResponse MapCreatureToResponse(Creature c) => new( + c.Id, c.Name, c.Description, c.Rarity, + c.BaseHealth, c.BaseAttack, c.BonusType, c.BonusValue); + + private static PlayerCreatureResponse MapPlayerCreatureToResponse(PlayerCreature pc) => new( + pc.Id, pc.CreatureId, pc.Creature?.Name ?? string.Empty, pc.Creature?.Description ?? string.Empty, + pc.Creature?.Rarity ?? CreatureRarity.Common, pc.Nickname, pc.Happiness, pc.Loyalty, + pc.Level, pc.Creature?.BonusType ?? string.Empty, pc.Creature?.BonusValue ?? 0, + pc.LastFedAt, pc.LastTrainedAt, pc.TamedAt); +} diff --git a/backend/WizardRPG.Api/Services/DungeonCrawlerService.cs b/backend/WizardRPG.Api/Services/DungeonCrawlerService.cs new file mode 100644 index 0000000..c81760a --- /dev/null +++ b/backend/WizardRPG.Api/Services/DungeonCrawlerService.cs @@ -0,0 +1,439 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.DungeonCrawler; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface IDungeonCrawlerService +{ + Task<(DungeonRunResponse Run, DungeonRoomResponse Room)> StartRunAsync(Guid playerId); + Task GetCurrentRoomAsync(Guid runId); + Task MakeChoiceAsync(Guid playerId, Guid runId, DungeonActionRequest request); + Task EscapeAsync(Guid playerId, Guid runId); + Task> GetRunHistoryAsync(Guid playerId); + Task<(DungeonRunResponse Run, DungeonRoomResponse Room)?> GetActiveRunAsync(Guid playerId); +} + +public class DungeonCrawlerService : IDungeonCrawlerService +{ + private readonly AppDbContext _db; + + public DungeonCrawlerService(AppDbContext db) => _db = db; + + public async Task<(DungeonRunResponse Run, DungeonRoomResponse Room)> StartRunAsync(Guid playerId) + { + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + var existingRun = await _db.DungeonRuns + .FirstOrDefaultAsync(r => r.PlayerId == playerId && r.Status == DungeonRunStatus.Active); + if (existingRun != null) + throw new InvalidOperationException("You already have an active dungeon run."); + + var maxHp = 80 + player.Strength / 2; + var run = new DungeonRun + { + PlayerId = playerId, + MaxHp = maxHp, + CurrentHp = maxHp + }; + + _db.DungeonRuns.Add(run); + await _db.SaveChangesAsync(); + + var room = GenerateRoom(run.Id, run.CurrentFloor); + return (MapRunToResponse(run), room); + } + + public Task GetCurrentRoomAsync(Guid runId) + { + return Task.FromResult(GetCurrentRoomInternal(runId, 0)); + } + + public async Task MakeChoiceAsync(Guid playerId, Guid runId, DungeonActionRequest request) + { + var run = await _db.DungeonRuns.FirstOrDefaultAsync(r => r.Id == runId && r.PlayerId == playerId) + ?? throw new KeyNotFoundException("Dungeon run not found."); + + if (run.Status != DungeonRunStatus.Active) + throw new InvalidOperationException("This dungeon run is no longer active."); + + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + var room = GenerateRoom(run.Id, run.CurrentFloor); + var choice = room.Choices.FirstOrDefault(c => c.Id == request.ChoiceId) + ?? throw new ArgumentException("Invalid choice."); + + var rng = new Random(run.Id.GetHashCode() + run.CurrentFloor * 1000 + request.ChoiceId.GetHashCode()); + var (narrative, hpChange, goldChange, xpChange) = ResolveChoice(room.Type, choice.Id, run, player, rng); + + run.CurrentHp = Math.Clamp(run.CurrentHp + hpChange, 0, run.MaxHp); + run.GoldCollected += goldChange; + run.XpCollected += xpChange; + + bool runEnded = false; + DungeonRoomResponse? nextRoom = null; + + if (run.CurrentHp <= 0) + { + run.Status = DungeonRunStatus.Defeated; + run.EndedAt = DateTime.UtcNow; + run.GoldCollected = 0; + run.XpCollected = 0; + runEnded = true; + narrative += " You have been defeated! All collected loot is lost."; + } + else if (choice.Id != "go_back") + { + run.CurrentFloor++; + nextRoom = GenerateRoom(run.Id, run.CurrentFloor); + } + else + { + nextRoom = GenerateRoom(run.Id, run.CurrentFloor); + } + + await _db.SaveChangesAsync(); + + return new DungeonActionResponse( + narrative, hpChange, goldChange, xpChange, + run.CurrentHp, run.GoldCollected, run.XpCollected, + runEnded, nextRoom); + } + + public async Task EscapeAsync(Guid playerId, Guid runId) + { + var run = await _db.DungeonRuns.FirstOrDefaultAsync(r => r.Id == runId && r.PlayerId == playerId) + ?? throw new KeyNotFoundException("Dungeon run not found."); + + if (run.Status != DungeonRunStatus.Active) + throw new InvalidOperationException("This dungeon run is no longer active."); + + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + player.GoldCoins += run.GoldCollected; + player.Experience += run.XpCollected; + + run.Status = DungeonRunStatus.Escaped; + run.EndedAt = DateTime.UtcNow; + + await _db.SaveChangesAsync(); + return MapRunToResponse(run); + } + + public async Task> GetRunHistoryAsync(Guid playerId) + { + var runs = await _db.DungeonRuns + .Where(r => r.PlayerId == playerId) + .OrderByDescending(r => r.StartedAt) + .ToListAsync(); + + return runs.Select(MapRunToResponse).ToList(); + } + + public async Task<(DungeonRunResponse Run, DungeonRoomResponse Room)?> GetActiveRunAsync(Guid playerId) + { + var run = await _db.DungeonRuns + .FirstOrDefaultAsync(r => r.PlayerId == playerId && r.Status == DungeonRunStatus.Active); + + if (run == null) return null; + + var room = GenerateRoom(run.Id, run.CurrentFloor); + return (MapRunToResponse(run), room); + } + + private DungeonRoomResponse GetCurrentRoomInternal(Guid runId, int floor) + { + return GenerateRoom(runId, floor == 0 ? 1 : floor); + } + + private static DungeonRoomResponse GenerateRoom(Guid runId, int floor) + { + var rng = new Random(runId.GetHashCode() + floor); + var roomType = PickRoomType(rng, floor); + var description = GenerateDescription(roomType, floor, rng); + var choices = GenerateChoices(roomType); + return new DungeonRoomResponse(roomType, description, choices); + } + + private static RoomType PickRoomType(Random rng, int floor) + { + if (floor == 5 || floor == 10) return RoomType.Boss; + + var roll = rng.Next(100); + + if (floor <= 3) + { + return roll switch + { + < 40 => RoomType.Monster, + < 70 => RoomType.Treasure, + _ => RoomType.Rest + }; + } + + if (floor <= 6) + { + return roll switch + { + < 30 => RoomType.Monster, + < 50 => RoomType.Treasure, + < 70 => RoomType.Trap, + < 85 => RoomType.Merchant, + _ => RoomType.Rest + }; + } + + return roll switch + { + < 35 => RoomType.Monster, + < 50 => RoomType.Treasure, + < 70 => RoomType.Trap, + < 85 => RoomType.Merchant, + _ => RoomType.Rest + }; + } + + private static string GenerateDescription(RoomType type, int floor, Random rng) + { + var descriptions = type switch + { + RoomType.Monster => new[] + { + "A snarling creature lurks in the shadows ahead.", + "You hear growling echoing off the dungeon walls.", + "A hostile beast blocks your path forward." + }, + RoomType.Treasure => new[] + { + "A gleaming chest sits in the center of the room.", + "Gold coins are scattered across the stone floor.", + "You spot a hidden cache behind a crumbling wall." + }, + RoomType.Trap => new[] + { + "The floor ahead looks suspiciously uneven.", + "Strange runes glow faintly on the walls.", + "You notice thin wires stretched across the corridor." + }, + RoomType.Merchant => new[] + { + "A hooded figure beckons you from a dimly lit alcove.", + "A traveling merchant has set up shop in this chamber.", + "An old wizard offers their services for a price." + }, + RoomType.Rest => new[] + { + "A quiet chamber with a small campfire provides respite.", + "You find a safe alcove where you can catch your breath.", + "A peaceful spring bubbles up from the dungeon floor." + }, + RoomType.Boss => new[] + { + $"A massive guardian of floor {floor} awaits your challenge!", + $"The dungeon boss of floor {floor} blocks the way forward!", + $"An ancient protector stands guard over floor {floor}!" + }, + _ => new[] { "You enter a mysterious room." } + }; + + return descriptions[rng.Next(descriptions.Length)]; + } + + private static List GenerateChoices(RoomType type) + { + return type switch + { + RoomType.Monster => + [ + new("fight", "Fight the creature", "Medium"), + new("sneak", "Sneak past", "Low"), + new("cast_spell", "Cast a spell", "Medium") + ], + RoomType.Treasure => + [ + new("open_carefully", "Open carefully", "Low"), + new("grab_and_run", "Grab and run", "Medium") + ], + RoomType.Trap => + [ + new("disarm", "Disarm the trap", "Medium"), + new("jump_across", "Jump across", "Medium"), + new("go_back", "Go back (safe)", "Low") + ], + RoomType.Merchant => + [ + new("buy_healing", "Buy healing (30 gold)", "Low"), + new("browse_and_leave", "Browse and leave", "Low") + ], + RoomType.Rest => + [ + new("rest_here", "Rest here", "Low"), + new("search_area", "Search the area", "Medium") + ], + RoomType.Boss => + [ + new("fight_boss", "Fight the boss", "High"), + new("use_magic", "Use magic", "Medium") + ], + _ => [new("proceed", "Proceed", "Low")] + }; + } + + private static (string Narrative, int HpChange, long GoldChange, int XpChange) ResolveChoice( + RoomType roomType, string choiceId, DungeonRun run, Player player, Random rng) + { + var floor = run.CurrentFloor; + return (roomType, choiceId) switch + { + (RoomType.Monster, "fight") => ResolveMonsterFight(floor, rng), + (RoomType.Monster, "sneak") => ResolveSneakPast(floor, player, rng), + (RoomType.Monster, "cast_spell") => ResolveCastSpell(floor, player, rng), + (RoomType.Treasure, "open_carefully") => ResolveTreasureCareful(floor, rng), + (RoomType.Treasure, "grab_and_run") => ResolveTreasureGrab(floor, rng), + (RoomType.Trap, "disarm") => ResolveDisarmTrap(floor, player, rng), + (RoomType.Trap, "jump_across") => ResolveJumpTrap(floor, player, rng), + (RoomType.Trap, "go_back") => ("You carefully retreat to safety.", 0, 0, 0), + (RoomType.Merchant, "buy_healing") => ResolveMerchantHeal(run), + (RoomType.Merchant, "browse_and_leave") => ("You browse the wares but find nothing of interest.", 0, 0, 5), + (RoomType.Rest, "rest_here") => ResolveRest(rng), + (RoomType.Rest, "search_area") => ResolveSearchArea(floor, rng), + (RoomType.Boss, "fight_boss") => ResolveBossFight(floor, rng), + (RoomType.Boss, "use_magic") => ResolveBossMagic(floor, player, rng), + _ => ("You proceed cautiously.", 0, 0, 5) + }; + } + + private static (string, int, long, int) ResolveMonsterFight(int floor, Random rng) + { + var damage = rng.Next(10, 31) * (floor / 3 + 1); + var gold = (long)(rng.Next(15, 41) * floor); + var xp = rng.Next(20, 41) * floor; + return ($"You fight bravely! You take {damage} damage but defeat the creature.", -damage, gold, xp); + } + + private static (string, int, long, int) ResolveSneakPast(int floor, Player player, Random rng) + { + var successChance = 40 + player.Speed * 2; + if (rng.Next(100) < successChance) + return ("You slip past the creature unnoticed.", 0, 0, rng.Next(5, 15) * floor); + + var damage = rng.Next(10, 21) * (floor / 3 + 1); + return ($"The creature spotted you! You take {damage} damage escaping.", -damage, 0, rng.Next(3, 8) * floor); + } + + private static (string, int, long, int) ResolveCastSpell(int floor, Player player, Random rng) + { + var successChance = 30 + player.MagicPower * 2; + if (rng.Next(100) < successChance) + { + var gold = (long)(rng.Next(20, 51) * floor); + var xp = rng.Next(25, 51) * floor; + return ($"Your spell obliterates the creature! You claim its treasure.", 0, gold, xp); + } + + var damage = rng.Next(15, 31) * (floor / 3 + 1); + var partialGold = (long)(rng.Next(5, 16) * floor); + return ($"Your spell fizzles! The creature strikes back for {damage} damage.", -damage, partialGold, rng.Next(10, 20) * floor); + } + + private static (string, int, long, int) ResolveTreasureCareful(int floor, Random rng) + { + var gold = (long)(rng.Next(20, 41) * floor); + return ($"You carefully open the chest and find {gold} gold!", 0, gold, rng.Next(5, 15)); + } + + private static (string, int, long, int) ResolveTreasureGrab(int floor, Random rng) + { + if (rng.Next(100) < 60) + { + var gold = (long)(rng.Next(40, 61) * floor); + return ($"You grab a massive haul of {gold} gold!", 0, gold, rng.Next(10, 20)); + } + + var damage = rng.Next(10, 21); + var partialGold = (long)(rng.Next(20, 36) * floor); + return ($"A trap springs! You take {damage} damage but still grab {partialGold} gold.", -damage, partialGold, rng.Next(8, 15)); + } + + private static (string, int, long, int) ResolveDisarmTrap(int floor, Player player, Random rng) + { + var successChance = 30 + player.Wisdom * 2; + if (rng.Next(100) < successChance) + { + var gold = (long)(rng.Next(10, 26) * floor); + return ($"You skillfully disarm the trap and salvage {gold} gold in parts!", 0, gold, rng.Next(15, 30) * floor); + } + + var damage = rng.Next(10, 26) * (floor / 2 + 1); + return ($"The trap triggers! You take {damage} damage.", -damage, 0, rng.Next(5, 10) * floor); + } + + private static (string, int, long, int) ResolveJumpTrap(int floor, Player player, Random rng) + { + var successChance = 35 + player.Speed * 2; + if (rng.Next(100) < successChance) + return ("You leap across the trap with agility!", 0, 0, rng.Next(10, 20) * floor); + + var damage = rng.Next(10, 26) * (floor / 2 + 1); + return ($"You stumble into the trap! You take {damage} damage.", -damage, 0, rng.Next(3, 8) * floor); + } + + private static (string, int, long, int) ResolveMerchantHeal(DungeonRun run) + { + if (run.GoldCollected < 30) + return ("You don't have enough gold for healing. The merchant waves you away.", 0, 0, 0); + + var healAmount = Math.Min(30, run.MaxHp - run.CurrentHp); + return ($"The merchant heals you for {healAmount} HP.", healAmount, -30, 5); + } + + private static (string, int, long, int) ResolveRest(Random rng) + { + var heal = rng.Next(15, 31); + return ($"You rest and recover {heal} HP.", heal, 0, 5); + } + + private static (string, int, long, int) ResolveSearchArea(int floor, Random rng) + { + if (rng.Next(100) < 50) + { + var gold = (long)(rng.Next(10, 31) * floor); + return ($"You find a hidden stash of {gold} gold!", 0, gold, rng.Next(5, 15)); + } + + var damage = rng.Next(5, 16); + return ($"A hidden trap springs! You take {damage} damage.", -damage, 0, rng.Next(3, 8)); + } + + private static (string, int, long, int) ResolveBossFight(int floor, Random rng) + { + var damage = rng.Next(20, 51) * (floor / 3 + 1); + var gold = (long)(rng.Next(50, 101) * floor); + var xp = rng.Next(50, 101) * floor; + return ($"An epic battle! The boss deals {damage} damage but you prevail!", -damage, gold, xp); + } + + private static (string, int, long, int) ResolveBossMagic(int floor, Player player, Random rng) + { + var successChance = 25 + player.MagicPower * 2; + if (rng.Next(100) < successChance) + { + var gold = (long)(rng.Next(60, 121) * floor); + var xp = rng.Next(60, 121) * floor; + return ($"Your powerful magic overwhelms the boss!", 0, gold, xp); + } + + var damage = rng.Next(25, 51) * (floor / 3 + 1); + var partialGold = (long)(rng.Next(20, 51) * floor); + return ($"The boss resists your magic and strikes for {damage} damage! You defeat it eventually.", -damage, partialGold, rng.Next(30, 60) * floor); + } + + private static DungeonRunResponse MapRunToResponse(DungeonRun r) => new( + r.Id, r.CurrentFloor, r.CurrentHp, r.MaxHp, + r.GoldCollected, r.XpCollected, r.Status, + r.StartedAt, r.EndedAt); +} diff --git a/backend/WizardRPG.Api/Services/PotionBrewingService.cs b/backend/WizardRPG.Api/Services/PotionBrewingService.cs new file mode 100644 index 0000000..6e7a141 --- /dev/null +++ b/backend/WizardRPG.Api/Services/PotionBrewingService.cs @@ -0,0 +1,112 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.PotionBrewing; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface IPotionBrewingService +{ + Task> GetRecipesAsync(); + Task> GetIngredientsAsync(); + Task BrewPotionAsync(Guid playerId, BrewPotionRequest request); + Task> GetPlayerBrewHistoryAsync(Guid playerId); +} + +public class PotionBrewingService : IPotionBrewingService +{ + private readonly AppDbContext _db; + + public PotionBrewingService(AppDbContext db) => _db = db; + + public async Task> GetRecipesAsync() + { + var recipes = await _db.PotionRecipes + .Include(r => r.Ingredients) + .ThenInclude(ri => ri.Ingredient) + .OrderBy(r => r.Difficulty) + .ToListAsync(); + + return recipes.Select(MapRecipeToResponse).ToList(); + } + + public async Task> GetIngredientsAsync() + { + var ingredients = await _db.PotionIngredients + .OrderBy(i => i.Name) + .ToListAsync(); + + return ingredients.Select(i => new PotionIngredientResponse( + i.Id, i.Name, i.Description, i.Price)).ToList(); + } + + public async Task BrewPotionAsync(Guid playerId, BrewPotionRequest request) + { + var recipe = await _db.PotionRecipes + .Include(r => r.Ingredients) + .ThenInclude(ri => ri.Ingredient) + .FirstOrDefaultAsync(r => r.Id == request.RecipeId) + ?? throw new KeyNotFoundException("Recipe not found."); + + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + // Calculate cost = sum of all ingredient prices * quantities + long cost = recipe.Ingredients.Sum(ri => ri.Ingredient!.Price * ri.Quantity); + + if (player.GoldCoins < cost) + throw new InvalidOperationException("Insufficient gold coins to purchase ingredients."); + + player.GoldCoins -= cost; + + // Determine brew result + int successChance = Math.Min(90, 50 + player.Wisdom - recipe.Difficulty * 8); + int roll = Random.Shared.Next(1, 101); + + BrewResult result; + if (roll <= successChance) + { + result = BrewResult.Success; + player.GoldCoins += recipe.GoldReward; + player.Experience += recipe.XpReward; + } + else if (roll > 95) + { + result = BrewResult.Explosion; + } + else + { + result = BrewResult.Failure; + } + + var attempt = new BrewAttempt + { + PlayerId = playerId, + RecipeId = recipe.Id, + Result = result + }; + + _db.BrewAttempts.Add(attempt); + await _db.SaveChangesAsync(); + + return new BrewAttemptResponse( + attempt.Id, recipe.Id, recipe.Name, attempt.Result, attempt.CreatedAt); + } + + public async Task> GetPlayerBrewHistoryAsync(Guid playerId) + { + var attempts = await _db.BrewAttempts + .Include(a => a.Recipe) + .Where(a => a.PlayerId == playerId) + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(); + + return attempts.Select(a => new BrewAttemptResponse( + a.Id, a.RecipeId, a.Recipe!.Name, a.Result, a.CreatedAt)).ToList(); + } + + private static PotionRecipeResponse MapRecipeToResponse(PotionRecipe r) => new( + r.Id, r.Name, r.Description, r.Difficulty, r.GoldReward, r.XpReward, + r.Ingredients.Select(ri => new RecipeIngredientResponse( + ri.IngredientId, ri.Ingredient!.Name, ri.Quantity)).ToList()); +} diff --git a/backend/WizardRPG.Api/Services/QuizService.cs b/backend/WizardRPG.Api/Services/QuizService.cs new file mode 100644 index 0000000..8e79296 --- /dev/null +++ b/backend/WizardRPG.Api/Services/QuizService.cs @@ -0,0 +1,136 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.Quiz; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface IQuizService +{ + Task> GetRandomQuestionsAsync(int count = 5, QuizDifficulty? difficulty = null); + Task SubmitQuizAsync(Guid playerId, SubmitQuizRequest request); + Task> GetQuizHistoryAsync(Guid playerId); + Task> GetLeaderboardAsync(int top = 10); +} + +public class QuizService : IQuizService +{ + private readonly AppDbContext _db; + + public QuizService(AppDbContext db) => _db = db; + + public async Task> GetRandomQuestionsAsync(int count = 5, QuizDifficulty? difficulty = null) + { + var query = _db.QuizQuestions.AsQueryable(); + if (difficulty.HasValue) + query = query.Where(q => q.Difficulty == difficulty.Value); + + var questions = await query + .OrderBy(q => EF.Functions.Random()) + .Take(count) + .ToListAsync(); + + return questions.Select(q => new QuizQuestionResponse( + q.Id, q.QuestionText, + q.OptionA, q.OptionB, q.OptionC, q.OptionD, + q.Difficulty, q.Category)).ToList(); + } + + public async Task SubmitQuizAsync(Guid playerId, SubmitQuizRequest request) + { + if (request.Answers.Count == 0) + throw new ArgumentException("No answers submitted."); + + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + var questionIds = request.Answers.Select(a => a.QuestionId).ToList(); + var questions = await _db.QuizQuestions + .Where(q => questionIds.Contains(q.Id)) + .ToDictionaryAsync(q => q.Id); + + var answerResults = new List(); + int score = 0; + long totalGold = 0; + int totalXp = 0; + + foreach (var answer in request.Answers) + { + if (!questions.TryGetValue(answer.QuestionId, out var question)) + continue; + + bool isCorrect = string.Equals(answer.SelectedOption, question.CorrectOption, StringComparison.OrdinalIgnoreCase); + + if (isCorrect) + { + score++; + totalGold += question.Difficulty switch + { + QuizDifficulty.Easy => 10, + QuizDifficulty.Medium => 20, + QuizDifficulty.Hard => 30, + _ => 10 + }; + totalXp += question.Difficulty switch + { + QuizDifficulty.Easy => 15, + QuizDifficulty.Medium => 25, + QuizDifficulty.Hard => 40, + _ => 15 + }; + } + + answerResults.Add(new QuizAnswerResult( + question.Id, answer.SelectedOption, question.CorrectOption, isCorrect)); + } + + player.GoldCoins += totalGold; + player.Experience += totalXp; + + var attempt = new QuizAttempt + { + PlayerId = playerId, + Score = score, + TotalQuestions = request.Answers.Count, + GoldEarned = totalGold, + XpEarned = totalXp + }; + + _db.QuizAttempts.Add(attempt); + await _db.SaveChangesAsync(); + + return new QuizResultResponse( + attempt.Id, attempt.Score, attempt.TotalQuestions, + attempt.GoldEarned, attempt.XpEarned, attempt.CompletedAt, + answerResults); + } + + public async Task> GetQuizHistoryAsync(Guid playerId) + { + var attempts = await _db.QuizAttempts + .Where(a => a.PlayerId == playerId) + .OrderByDescending(a => a.CompletedAt) + .ToListAsync(); + + return attempts.Select(a => new QuizResultResponse( + a.Id, a.Score, a.TotalQuestions, + a.GoldEarned, a.XpEarned, a.CompletedAt, + new List())).ToList(); + } + + public async Task> GetLeaderboardAsync(int top = 10) + { + var leaderboard = await _db.QuizAttempts + .Include(a => a.Player) + .GroupBy(a => new { a.PlayerId, a.Player!.Username }) + .Select(g => new QuizLeaderboardEntry( + g.Key.Username, + g.Sum(a => a.Score), + g.Count())) + .OrderByDescending(e => e.TotalScore) + .Take(top) + .ToListAsync(); + + return leaderboard; + } +} diff --git a/backend/WizardRPG.Api/Services/WhisperingWallsService.cs b/backend/WizardRPG.Api/Services/WhisperingWallsService.cs new file mode 100644 index 0000000..9575cd4 --- /dev/null +++ b/backend/WizardRPG.Api/Services/WhisperingWallsService.cs @@ -0,0 +1,228 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.WhisperingWalls; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface IWhisperingWallsService +{ + Task> GetStoryArcsAsync(); + Task StartStoryAsync(Guid playerId, string storyArc); + Task GetCurrentChapterAsync(Guid playerId, string storyArc); + Task MakeChoiceAsync(Guid playerId, MakeChoiceRequest request); + Task> GetProgressAsync(Guid playerId); +} + +public class WhisperingWallsService : IWhisperingWallsService +{ + private readonly AppDbContext _db; + + public WhisperingWallsService(AppDbContext db) => _db = db; + + public async Task> GetStoryArcsAsync() + { + var arcs = await _db.StoryChapters + .GroupBy(c => c.StoryArc) + .Select(g => new StoryArcResponse( + g.Key, + g.OrderBy(c => c.OrderIndex).First().Title, + g.Count())) + .ToListAsync(); + + return arcs; + } + + public async Task StartStoryAsync(Guid playerId, string storyArc) + { + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + var firstChapter = await _db.StoryChapters + .Include(c => c.Choices) + .Where(c => c.StoryArc == storyArc) + .OrderBy(c => c.OrderIndex) + .FirstOrDefaultAsync() + ?? throw new KeyNotFoundException($"Story arc '{storyArc}' not found."); + + // Check for existing progress on this story arc + var existing = await _db.PlayerStoryProgress + .FirstOrDefaultAsync(p => p.PlayerId == playerId && p.StoryArc == storyArc); + + if (existing != null) + { + // Reset progress to restart the story + existing.CurrentChapterId = firstChapter.Id; + existing.IsCompleted = false; + existing.CompletedAt = null; + existing.StartedAt = DateTime.UtcNow; + } + else + { + var progress = new PlayerStoryProgress + { + PlayerId = playerId, + CurrentChapterId = firstChapter.Id, + StoryArc = storyArc + }; + _db.PlayerStoryProgress.Add(progress); + } + + await _db.SaveChangesAsync(); + return await MapChapterToResponse(firstChapter, playerId); + } + + public async Task GetCurrentChapterAsync(Guid playerId, string storyArc) + { + var progress = await _db.PlayerStoryProgress + .FirstOrDefaultAsync(p => p.PlayerId == playerId && p.StoryArc == storyArc) + ?? throw new KeyNotFoundException("No progress found for this story arc. Start the story first."); + + var chapter = await _db.StoryChapters + .Include(c => c.Choices) + .FirstOrDefaultAsync(c => c.Id == progress.CurrentChapterId) + ?? throw new KeyNotFoundException("Current chapter not found."); + + return await MapChapterToResponse(chapter, playerId); + } + + public async Task MakeChoiceAsync(Guid playerId, MakeChoiceRequest request) + { + var choice = await _db.StoryChoices + .Include(c => c.Chapter) + .Include(c => c.NextChapter) + .ThenInclude(nc => nc!.Choices) + .FirstOrDefaultAsync(c => c.Id == request.ChoiceId) + ?? throw new KeyNotFoundException("Choice not found."); + + var progress = await _db.PlayerStoryProgress + .FirstOrDefaultAsync(p => p.PlayerId == playerId && p.StoryArc == choice.Chapter!.StoryArc) + ?? throw new KeyNotFoundException("No progress found for this story. Start the story first."); + + if (progress.IsCompleted) + throw new InvalidOperationException("This story is already completed. Start it again to replay."); + + if (progress.CurrentChapterId != choice.ChapterId) + throw new InvalidOperationException("This choice does not belong to your current chapter."); + + // Check requirements + var player = await _db.Players + .Include(p => p.BankItems) + .ThenInclude(bi => bi.Item) + .FirstOrDefaultAsync(p => p.Id == playerId) + ?? throw new KeyNotFoundException("Player not found."); + + if (choice.RequiredItemName != null) + { + var hasItem = player.BankItems.Any(bi => bi.Item != null && bi.Item.Name == choice.RequiredItemName); + if (!hasItem) + throw new InvalidOperationException($"You need '{choice.RequiredItemName}' to make this choice."); + } + + if (choice.MinWisdom.HasValue && player.Wisdom < choice.MinWisdom.Value) + throw new InvalidOperationException($"You need at least {choice.MinWisdom.Value} Wisdom to make this choice."); + + if (choice.NextChapterId == null) + throw new InvalidOperationException("This choice leads nowhere."); + + var nextChapter = choice.NextChapter!; + + // Advance progress + progress.CurrentChapterId = nextChapter.Id; + + int? goldEarned = null; + int? xpEarned = null; + + if (nextChapter.IsEnding) + { + progress.IsCompleted = true; + progress.CompletedAt = DateTime.UtcNow; + + if (nextChapter.GoldReward.HasValue) + { + player.GoldCoins += nextChapter.GoldReward.Value; + goldEarned = nextChapter.GoldReward.Value; + } + + if (nextChapter.XpReward.HasValue) + { + player.Experience += nextChapter.XpReward.Value; + xpEarned = nextChapter.XpReward.Value; + } + } + + await _db.SaveChangesAsync(); + + var chapterResponse = await MapChapterToResponse(nextChapter, playerId); + + return new MakeChoiceResponse( + nextChapter.Content, + nextChapter.IsEnding, + goldEarned, + xpEarned, + chapterResponse); + } + + public async Task> GetProgressAsync(Guid playerId) + { + var progress = await _db.PlayerStoryProgress + .Include(p => p.CurrentChapter) + .Where(p => p.PlayerId == playerId) + .OrderByDescending(p => p.StartedAt) + .ToListAsync(); + + return progress.Select(p => new PlayerStoryProgressResponse( + p.Id, + p.StoryArc, + p.CurrentChapterId, + p.CurrentChapter?.Title ?? "Unknown", + p.IsCompleted, + p.StartedAt, + p.CompletedAt)).ToList(); + } + + private async Task MapChapterToResponse(StoryChapter chapter, Guid playerId) + { + var player = await _db.Players + .Include(p => p.BankItems) + .ThenInclude(bi => bi.Item) + .FirstOrDefaultAsync(p => p.Id == playerId); + + var choiceResponses = chapter.Choices.Select(c => + { + var isAvailable = true; + string? hint = null; + + if (c.RequiredItemName != null) + { + var hasItem = player?.BankItems.Any(bi => bi.Item != null && bi.Item.Name == c.RequiredItemName) ?? false; + if (!hasItem) + { + isAvailable = false; + hint = $"Requires {c.RequiredItemName}"; + } + } + + if (c.MinWisdom.HasValue) + { + if ((player?.Wisdom ?? 0) < c.MinWisdom.Value) + { + isAvailable = false; + hint = $"Requires {c.MinWisdom.Value} Wisdom"; + } + } + + return new StoryChoiceResponse(c.Id, c.ChoiceText, isAvailable, hint); + }).ToList(); + + return new StoryChapterResponse( + chapter.Id, + chapter.Title, + chapter.Content, + chapter.StoryArc, + chapter.IsEnding, + chapter.GoldReward, + chapter.XpReward, + choiceResponses); + } +} diff --git a/backend/WizardRPG.Api/Services/WizardChessService.cs b/backend/WizardRPG.Api/Services/WizardChessService.cs new file mode 100644 index 0000000..10d3c5d --- /dev/null +++ b/backend/WizardRPG.Api/Services/WizardChessService.cs @@ -0,0 +1,471 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.WizardChess; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface IWizardChessService +{ + Task CreateMatchAsync(Guid playerId, CreateChessMatchRequest request); + Task GetMatchAsync(Guid matchId); + Task> GetPlayerMatchesAsync(Guid playerId); + Task MakeMoveAsync(Guid playerId, Guid matchId, ChessMoveRequest request); + Task ForfeitAsync(Guid playerId, Guid matchId); +} + +public class WizardChessService : IWizardChessService +{ + private const int BoardSize = 6; + private static readonly Random Rng = new(); + + private readonly AppDbContext _db; + + public WizardChessService(AppDbContext db) => _db = db; + + public async Task CreateMatchAsync(Guid playerId, CreateChessMatchRequest request) + { + if (request.BetAmount < 0) + throw new ArgumentException("Bet amount cannot be negative."); + + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + if (player.GoldCoins < request.BetAmount) + throw new InvalidOperationException("Insufficient gold coins."); + + Player? defender = null; + if (request.DefenderId.HasValue) + { + defender = await _db.Players.FindAsync(request.DefenderId.Value) + ?? throw new KeyNotFoundException("Defender not found."); + } + + player.GoldCoins -= request.BetAmount; + + var match = new ChessMatch + { + ChallengerId = playerId, + DefenderId = request.DefenderId, + BetAmount = request.BetAmount, + Status = ChessMatchStatus.Active, + BoardState = SerializeBoard(CreateInitialBoard()), + IsPlayerTurn = true, + TurnCount = 0 + }; + + _db.ChessMatches.Add(match); + await _db.SaveChangesAsync(); + + return MapToResponse(match, player.Username, defender?.Username, null); + } + + public async Task GetMatchAsync(Guid matchId) + { + var match = await _db.ChessMatches + .Include(m => m.Challenger) + .Include(m => m.Defender) + .Include(m => m.Winner) + .FirstOrDefaultAsync(m => m.Id == matchId) + ?? throw new KeyNotFoundException("Match not found."); + + return MapToResponse(match, + match.Challenger!.Username, + match.Defender?.Username, + match.Winner?.Username); + } + + public async Task> GetPlayerMatchesAsync(Guid playerId) + { + var matches = await _db.ChessMatches + .Include(m => m.Challenger) + .Include(m => m.Defender) + .Include(m => m.Winner) + .Where(m => m.ChallengerId == playerId || m.DefenderId == playerId) + .OrderByDescending(m => m.CreatedAt) + .ToListAsync(); + + return matches.Select(m => MapToResponse(m, + m.Challenger!.Username, + m.Defender?.Username, + m.Winner?.Username)).ToList(); + } + + public async Task MakeMoveAsync(Guid playerId, Guid matchId, ChessMoveRequest request) + { + var match = await _db.ChessMatches + .Include(m => m.Challenger) + .Include(m => m.Defender) + .Include(m => m.Winner) + .FirstOrDefaultAsync(m => m.Id == matchId) + ?? throw new KeyNotFoundException("Match not found."); + + if (match.Status != ChessMatchStatus.Active) + throw new InvalidOperationException("Match is not active."); + + bool isPvE = match.DefenderId == null; + bool isChallenger = match.ChallengerId == playerId; + bool isDefender = match.DefenderId == playerId; + + if (!isChallenger && !isDefender) + throw new InvalidOperationException("You are not a participant in this match."); + + if (isPvE && !isChallenger) + throw new InvalidOperationException("You are not a participant in this match."); + + // In PvE, only challenger moves. In PvP, check whose turn it is. + if (isPvE && !match.IsPlayerTurn) + throw new InvalidOperationException("It is not your turn."); + if (!isPvE && match.IsPlayerTurn && !isChallenger) + throw new InvalidOperationException("It is not your turn."); + if (!isPvE && !match.IsPlayerTurn && !isDefender) + throw new InvalidOperationException("It is not your turn."); + + var board = DeserializeBoard(match.BoardState); + + // Determine which side the current player controls + bool playsLowercase = isPvE ? true : isChallenger; + + if (!IsValidMove(board, request.FromRow, request.FromCol, request.ToRow, request.ToCol, playsLowercase, request.UseAbility)) + return new ChessMoveResponse(false, "Invalid move.", match.BoardState, match.IsPlayerTurn, false, null, null); + + string capturedPiece = board[request.ToRow][request.ToCol]; + board[request.ToRow][request.ToCol] = board[request.FromRow][request.FromCol]; + board[request.FromRow][request.FromCol] = "."; + match.TurnCount++; + + string narrative = $"Moved {PieceName(board[request.ToRow][request.ToCol])} from ({request.FromRow},{request.FromCol}) to ({request.ToRow},{request.ToCol})."; + + if (capturedPiece != ".") + narrative += $" Captured {PieceName(capturedPiece)}!"; + + // Check if opponent King was captured + bool gameOver = false; + Guid? winnerId = null; + string? winnerUsername = null; + + if (capturedPiece == "K") // Opponent (uppercase) King captured by player (lowercase) + { + gameOver = true; + winnerId = playsLowercase ? match.ChallengerId : match.DefenderId; + } + else if (capturedPiece == "k") // Player (lowercase) King captured by opponent (uppercase) + { + gameOver = true; + winnerId = playsLowercase ? match.DefenderId : match.ChallengerId; + if (isPvE) winnerId = null; // AI wins, no player winner + } + + if (gameOver) + { + match.Status = ChessMatchStatus.Finished; + match.FinishedAt = DateTime.UtcNow; + match.WinnerId = winnerId; + match.BoardState = SerializeBoard(board); + await AwardWinningsAsync(match); + await _db.SaveChangesAsync(); + + winnerUsername = winnerId.HasValue + ? (await _db.Players.FindAsync(winnerId.Value))?.Username + : "AI"; + + narrative += winnerId.HasValue ? $" {winnerUsername} wins!" : " The AI wins!"; + + return new ChessMoveResponse(true, narrative, match.BoardState, match.IsPlayerTurn, true, winnerId, winnerUsername); + } + + // PvE: AI makes a move after the player + if (isPvE) + { + match.IsPlayerTurn = false; + var aiNarrative = MakeAiMove(board); + match.IsPlayerTurn = true; + match.TurnCount++; + narrative += " " + aiNarrative; + + // Check if AI captured player's King + if (!BoardContains(board, "k")) + { + gameOver = true; + match.Status = ChessMatchStatus.Finished; + match.FinishedAt = DateTime.UtcNow; + match.WinnerId = null; // AI won + match.BoardState = SerializeBoard(board); + await _db.SaveChangesAsync(); + narrative += " The AI wins!"; + return new ChessMoveResponse(true, narrative, match.BoardState, true, true, null, "AI"); + } + + // Check if player captured AI King during the player's move (already handled above) + } + else + { + match.IsPlayerTurn = !match.IsPlayerTurn; + } + + match.BoardState = SerializeBoard(board); + await _db.SaveChangesAsync(); + + return new ChessMoveResponse(true, narrative, match.BoardState, match.IsPlayerTurn, false, null, null); + } + + public async Task ForfeitAsync(Guid playerId, Guid matchId) + { + var match = await _db.ChessMatches + .Include(m => m.Challenger) + .Include(m => m.Defender) + .Include(m => m.Winner) + .FirstOrDefaultAsync(m => m.Id == matchId) + ?? throw new KeyNotFoundException("Match not found."); + + if (match.Status != ChessMatchStatus.Active) + throw new InvalidOperationException("Match is not active."); + + bool isChallenger = match.ChallengerId == playerId; + bool isDefender = match.DefenderId == playerId; + if (!isChallenger && !isDefender) + throw new InvalidOperationException("You are not a participant in this match."); + + match.Status = ChessMatchStatus.Forfeit; + match.FinishedAt = DateTime.UtcNow; + + if (match.DefenderId == null) + { + // PvE forfeit - player loses bet + match.WinnerId = null; + } + else + { + match.WinnerId = isChallenger ? match.DefenderId : match.ChallengerId; + await AwardWinningsAsync(match); + } + + await _db.SaveChangesAsync(); + + return MapToResponse(match, + match.Challenger!.Username, + match.Defender?.Username, + match.Winner?.Username); + } + + // ── Board helpers ────────────────────────────────────────────────── + + private static string[][] CreateInitialBoard() + { + return new[] + { + new[] { "G", "N", "W", "K", "N", "G" }, + new[] { "P", "P", "P", "P", "P", "P" }, + new[] { ".", ".", ".", ".", ".", "." }, + new[] { ".", ".", ".", ".", ".", "." }, + new[] { "p", "p", "p", "p", "p", "p" }, + new[] { "g", "n", "w", "k", "n", "g" } + }; + } + + private static string SerializeBoard(string[][] board) => + JsonSerializer.Serialize(board); + + private static string[][] DeserializeBoard(string boardState) => + JsonSerializer.Deserialize(boardState) + ?? throw new InvalidOperationException("Invalid board state."); + + private static bool BoardContains(string[][] board, string piece) + { + for (int r = 0; r < BoardSize; r++) + for (int c = 0; c < BoardSize; c++) + if (board[r][c] == piece) + return true; + return false; + } + + // ── Move validation ──────────────────────────────────────────────── + + private static bool IsValidMove(string[][] board, int fr, int fc, int tr, int tc, bool playsLowercase, bool useAbility) + { + if (fr < 0 || fr >= BoardSize || fc < 0 || fc >= BoardSize) return false; + if (tr < 0 || tr >= BoardSize || tc < 0 || tc >= BoardSize) return false; + if (fr == tr && fc == tc) return false; + + string piece = board[fr][fc]; + if (piece == ".") return false; + + bool isOwn = playsLowercase ? char.IsLower(piece[0]) : char.IsUpper(piece[0]); + if (!isOwn) return false; + + string target = board[tr][tc]; + if (target != ".") + { + bool targetIsOwn = playsLowercase ? char.IsLower(target[0]) : char.IsUpper(target[0]); + if (targetIsOwn) return false; + + // Golem ability: can't be captured from the front + if (useAbility && (target == "G" || target == "g")) + { + int direction = playsLowercase ? -1 : 1; // lowercase moves "up" (decreasing row) + if (tr - fr == direction && fc == tc) return false; // attacking from front + } + } + + string normalizedPiece = piece.ToUpperInvariant(); + int dr = tr - fr; + int dc = tc - fc; + + return normalizedPiece switch + { + "P" => IsValidPawnMove(fr, fc, tr, tc, dr, dc, playsLowercase, board, target), + "G" => IsValidGolemMove(dr, dc, board, fr, fc, tr, tc), + "N" => IsValidKnightMove(dr, dc), + "W" => IsValidWizardMove(dr, dc, board, fr, fc, tr, tc), + "K" => IsValidKingMove(dr, dc), + "B" => IsValidPhoenixMove(dr, dc, board, fr, fc, tr, tc), + _ => false + }; + } + + private static bool IsValidPawnMove(int fr, int fc, int tr, int tc, int dr, int dc, bool playsLowercase, string[][] board, string target) + { + int forward = playsLowercase ? -1 : 1; + if (dc == 0 && dr == forward && target == ".") + return true; + if (Math.Abs(dc) == 1 && dr == forward && target != ".") + return true; + return false; + } + + private static bool IsValidGolemMove(int dr, int dc, string[][] board, int fr, int fc, int tr, int tc) + { + if (dr != 0 && dc != 0) return false; + return IsPathClear(board, fr, fc, tr, tc); + } + + private static bool IsValidKnightMove(int dr, int dc) + { + int adr = Math.Abs(dr); + int adc = Math.Abs(dc); + return (adr == 2 && adc == 1) || (adr == 1 && adc == 2); + } + + private static bool IsValidWizardMove(int dr, int dc, string[][] board, int fr, int fc, int tr, int tc) + { + if (dr == 0 || dc == 0) + return IsPathClear(board, fr, fc, tr, tc); + if (Math.Abs(dr) == Math.Abs(dc)) + return IsPathClear(board, fr, fc, tr, tc); + return false; + } + + private static bool IsValidKingMove(int dr, int dc) + { + return Math.Abs(dr) <= 1 && Math.Abs(dc) <= 1; + } + + private static bool IsPathClear(string[][] board, int fr, int fc, int tr, int tc) + { + int dr = Math.Sign(tr - fr); + int dc = Math.Sign(tc - fc); + int r = fr + dr; + int c = fc + dc; + while (r != tr || c != tc) + { + if (board[r][c] != ".") return false; + r += dr; + c += dc; + } + return true; + } + + // Phoenix (B/b) uses bishop-like diagonal movement; not in starting lineup, + // appears only via the Phoenix resurrection ability. + + private static bool IsValidPhoenixMove(int dr, int dc, string[][] board, int fr, int fc, int tr, int tc) + { + if (Math.Abs(dr) != Math.Abs(dc)) return false; + return IsPathClear(board, fr, fc, tr, tc); + } + + // ── AI moves ─────────────────────────────────────────────────────── + + private static string MakeAiMove(string[][] board) + { + // AI plays uppercase pieces + var validMoves = new List<(int fr, int fc, int tr, int tc)>(); + + for (int r = 0; r < BoardSize; r++) + { + for (int c = 0; c < BoardSize; c++) + { + if (board[r][c] == "." || char.IsLower(board[r][c][0])) + continue; + + for (int tr = 0; tr < BoardSize; tr++) + { + for (int tc = 0; tc < BoardSize; tc++) + { + if (IsValidMove(board, r, c, tr, tc, false, false)) + validMoves.Add((r, c, tr, tc)); + } + } + } + } + + if (validMoves.Count == 0) + return "AI has no valid moves."; + + var move = validMoves[Rng.Next(validMoves.Count)]; + string aiPiece = board[move.fr][move.fc]; + string captured = board[move.tr][move.tc]; + board[move.tr][move.tc] = board[move.fr][move.fc]; + board[move.fr][move.fc] = "."; + + string aiNarrative = $"AI moved {PieceName(aiPiece)} from ({move.fr},{move.fc}) to ({move.tr},{move.tc})."; + if (captured != ".") + aiNarrative += $" AI captured {PieceName(captured)}!"; + + return aiNarrative; + } + + // ── Gold management ──────────────────────────────────────────────── + + private async Task AwardWinningsAsync(ChessMatch match) + { + if (match.WinnerId == null) + return; // AI won or no winner + + var winner = await _db.Players.FindAsync(match.WinnerId.Value); + if (winner == null) return; + + if (match.DefenderId == null) + { + // PvE: winner gets bet back + equal amount + winner.GoldCoins += match.BetAmount * 2; + } + else + { + // PvP: winner gets double bet + winner.GoldCoins += match.BetAmount * 2; + } + } + + // ── Mapping ──────────────────────────────────────────────────────── + + private static ChessMatchResponse MapToResponse(ChessMatch m, string challengerUsername, string? defenderUsername, string? winnerUsername) => + new(m.Id, m.ChallengerId, challengerUsername, + m.DefenderId, defenderUsername, + m.WinnerId, winnerUsername, + m.Status, m.BetAmount, + m.BoardState, m.IsPlayerTurn, m.TurnCount, + m.CreatedAt, m.FinishedAt); + + private static string PieceName(string piece) => piece.ToUpperInvariant() switch + { + "K" => "King", + "W" => "Wizard", + "N" => "Knight", + "G" => "Golem", + "B" => "Phoenix", + "P" => "Pawn", + _ => "Unknown" + }; +}