From 79c603f8bd383c5182770db6956fdff0dd1fb355 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 21:56:40 +0000
Subject: [PATCH 1/7] Initial plan
From c431049843a2339885624d9d751604ff494a27c3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:04:47 +0000
Subject: [PATCH 2/7] feat: add Potion Brewing Lab mini-game backend
Add complete backend implementation for the Potion Brewing Lab mini-game:
- Model: PotionRecipe, PotionIngredient, RecipeIngredient, BrewAttempt
- DTOs: request/response records following existing BroomGame pattern
- Service: IPotionBrewingService with recipe listing, ingredient listing,
brew attempts with success formula based on wisdom/difficulty, and history
- Controller: PotionBrewingController with auth/anon endpoints
- DbContext: new DbSets and entity configurations with proper FKs
- Program.cs: DI registration for the new service
- SeedData: 6 ingredients and 4 recipes with varying difficulty
- Player model: added BrewAttempts navigation property
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jwraats <3438726+jwraats@users.noreply.github.com>
---
.../Controllers/PotionBrewingController.cs | 55 +++++++++
.../DTOs/PotionBrewing/PotionBrewingDtos.cs | 22 ++++
backend/WizardRPG.Api/Data/AppDbContext.cs | 41 +++++++
backend/WizardRPG.Api/Data/SeedData.cs | 76 ++++++++++++
backend/WizardRPG.Api/Models/Player.cs | 1 +
backend/WizardRPG.Api/Models/PotionBrewing.cs | 54 +++++++++
backend/WizardRPG.Api/Program.cs | 1 +
.../Services/PotionBrewingService.cs | 112 ++++++++++++++++++
8 files changed, 362 insertions(+)
create mode 100644 backend/WizardRPG.Api/Controllers/PotionBrewingController.cs
create mode 100644 backend/WizardRPG.Api/DTOs/PotionBrewing/PotionBrewingDtos.cs
create mode 100644 backend/WizardRPG.Api/Models/PotionBrewing.cs
create mode 100644 backend/WizardRPG.Api/Services/PotionBrewingService.cs
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/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/Data/AppDbContext.cs b/backend/WizardRPG.Api/Data/AppDbContext.cs
index f33435e..0068182 100644
--- a/backend/WizardRPG.Api/Data/AppDbContext.cs
+++ b/backend/WizardRPG.Api/Data/AppDbContext.cs
@@ -19,6 +19,10 @@ 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();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -151,5 +155,42 @@ 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);
+ });
}
}
diff --git a/backend/WizardRPG.Api/Data/SeedData.cs b/backend/WizardRPG.Api/Data/SeedData.cs
index ee33cdd..80d3a37 100644
--- a/backend/WizardRPG.Api/Data/SeedData.cs
+++ b/backend/WizardRPG.Api/Data/SeedData.cs
@@ -33,5 +33,81 @@ 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();
+ }
}
}
diff --git a/backend/WizardRPG.Api/Models/Player.cs b/backend/WizardRPG.Api/Models/Player.cs
index 06aee8d..eff90af 100644
--- a/backend/WizardRPG.Api/Models/Player.cs
+++ b/backend/WizardRPG.Api/Models/Player.cs
@@ -23,4 +23,5 @@ 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();
}
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/Program.cs b/backend/WizardRPG.Api/Program.cs
index f7abecb..b55ffbb 100644
--- a/backend/WizardRPG.Api/Program.cs
+++ b/backend/WizardRPG.Api/Program.cs
@@ -55,6 +55,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
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());
+}
From d8f473717b0ac5983dbe25186f6b98e8acbd0e9a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:11:32 +0000
Subject: [PATCH 3/7] feat: add Enchanted Library Quiz mini-game backend
- Add QuizQuestion and QuizAttempt models with enums for difficulty/category
- Add Quiz DTOs (QuizQuestionResponse, SubmitQuizRequest, QuizResultResponse, etc.)
- Add IQuizService/QuizService with question retrieval, answer validation, scoring, and leaderboard
- Add QuizController with GET questions, POST submit, GET history, GET leaderboard endpoints
- Update AppDbContext with QuizQuestion/QuizAttempt DbSets and entity configurations
- Update Player model with QuizAttempts navigation property
- Register IQuizService in Program.cs DI container
- Seed 12 quiz questions (3 per category: SpellLore, MagicalCreatures, PotionIngredients, WizardHistory)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jwraats <3438726+jwraats@users.noreply.github.com>
---
.../Controllers/QuizController.cs | 58 ++++++++
backend/WizardRPG.Api/DTOs/Quiz/QuizDtos.cs | 29 ++++
backend/WizardRPG.Api/Data/AppDbContext.cs | 16 +++
backend/WizardRPG.Api/Data/SeedData.cs | 37 +++++
backend/WizardRPG.Api/Models/Player.cs | 1 +
backend/WizardRPG.Api/Models/Quiz.cs | 42 ++++++
backend/WizardRPG.Api/Program.cs | 1 +
backend/WizardRPG.Api/Services/QuizService.cs | 136 ++++++++++++++++++
8 files changed, 320 insertions(+)
create mode 100644 backend/WizardRPG.Api/Controllers/QuizController.cs
create mode 100644 backend/WizardRPG.Api/DTOs/Quiz/QuizDtos.cs
create mode 100644 backend/WizardRPG.Api/Models/Quiz.cs
create mode 100644 backend/WizardRPG.Api/Services/QuizService.cs
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/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/Data/AppDbContext.cs b/backend/WizardRPG.Api/Data/AppDbContext.cs
index 0068182..d9ba7c6 100644
--- a/backend/WizardRPG.Api/Data/AppDbContext.cs
+++ b/backend/WizardRPG.Api/Data/AppDbContext.cs
@@ -23,6 +23,8 @@ public AppDbContext(DbContextOptions options) : base(options) { }
public DbSet PotionIngredients => Set();
public DbSet RecipeIngredients => Set();
public DbSet BrewAttempts => Set();
+ public DbSet QuizQuestions => Set();
+ public DbSet QuizAttempts => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -192,5 +194,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.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);
+ });
}
}
diff --git a/backend/WizardRPG.Api/Data/SeedData.cs b/backend/WizardRPG.Api/Data/SeedData.cs
index 80d3a37..9e6c5a0 100644
--- a/backend/WizardRPG.Api/Data/SeedData.cs
+++ b/backend/WizardRPG.Api/Data/SeedData.cs
@@ -109,5 +109,42 @@ public static void Seed(AppDbContext context)
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();
+ }
}
}
diff --git a/backend/WizardRPG.Api/Models/Player.cs b/backend/WizardRPG.Api/Models/Player.cs
index eff90af..a5fbc06 100644
--- a/backend/WizardRPG.Api/Models/Player.cs
+++ b/backend/WizardRPG.Api/Models/Player.cs
@@ -24,4 +24,5 @@ public class Player
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();
}
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/Program.cs b/backend/WizardRPG.Api/Program.cs
index b55ffbb..c42a95a 100644
--- a/backend/WizardRPG.Api/Program.cs
+++ b/backend/WizardRPG.Api/Program.cs
@@ -55,6 +55,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
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;
+ }
+}
From b3e178cde9339682e406c02e6196e829a0e1d2ef Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:18:06 +0000
Subject: [PATCH 4/7] feat: implement Dungeon Crawler mini-game backend
Add roguelike dungeon exploration mini-game with:
- DungeonRun model with floor/HP/gold/XP tracking and permadeath
- Procedural room generation (Monster/Treasure/Trap/Merchant/Rest/Boss)
- Player stat-based choice resolution (strength, magic, wisdom, speed)
- Risk/reward mechanics with escape-with-loot or push-deeper gameplay
- RESTful API endpoints for start/action/escape/history
- Full EF Core integration with Player navigation property
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jwraats <3438726+jwraats@users.noreply.github.com>
---
.../Controllers/DungeonCrawlerController.cs | 65 +++
.../DTOs/DungeonCrawler/DungeonCrawlerDtos.cs | 20 +
backend/WizardRPG.Api/Data/AppDbContext.cs | 12 +
.../WizardRPG.Api/Models/DungeonCrawler.cs | 34 ++
backend/WizardRPG.Api/Models/Player.cs | 1 +
backend/WizardRPG.Api/Program.cs | 1 +
.../Services/DungeonCrawlerService.cs | 439 ++++++++++++++++++
7 files changed, 572 insertions(+)
create mode 100644 backend/WizardRPG.Api/Controllers/DungeonCrawlerController.cs
create mode 100644 backend/WizardRPG.Api/DTOs/DungeonCrawler/DungeonCrawlerDtos.cs
create mode 100644 backend/WizardRPG.Api/Models/DungeonCrawler.cs
create mode 100644 backend/WizardRPG.Api/Services/DungeonCrawlerService.cs
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/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/Data/AppDbContext.cs b/backend/WizardRPG.Api/Data/AppDbContext.cs
index d9ba7c6..6c7bc03 100644
--- a/backend/WizardRPG.Api/Data/AppDbContext.cs
+++ b/backend/WizardRPG.Api/Data/AppDbContext.cs
@@ -25,6 +25,7 @@ public AppDbContext(DbContextOptions options) : base(options) { }
public DbSet BrewAttempts => Set();
public DbSet QuizQuestions => Set();
public DbSet QuizAttempts => Set();
+ public DbSet DungeonRuns => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -208,5 +209,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.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);
+ });
}
}
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 a5fbc06..cb3b788 100644
--- a/backend/WizardRPG.Api/Models/Player.cs
+++ b/backend/WizardRPG.Api/Models/Player.cs
@@ -25,4 +25,5 @@ public class Player
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();
}
diff --git a/backend/WizardRPG.Api/Program.cs b/backend/WizardRPG.Api/Program.cs
index c42a95a..f654d74 100644
--- a/backend/WizardRPG.Api/Program.cs
+++ b/backend/WizardRPG.Api/Program.cs
@@ -60,6 +60,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
// SignalR
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);
+}
From 87908e07423bd867b76318115f420cc63d908582 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:22:08 +0000
Subject: [PATCH 5/7] feat: add Magical Creature Taming mini-game backend
Add creature taming system with explore, tame, and care mechanics:
- Creature and PlayerCreature models with mood/loyalty system
- CreatureTamingService with explore (50g cost, rarity-weighted drops),
tame, care (feed/train/rest with cooldowns), and bonus calculation
- CreatureTamingController with 6 endpoints matching existing patterns
- 8 seed creatures (2 each: common, uncommon, rare, legendary)
- Creatures with loyalty >50 provide passive stat bonuses scaled by level
- Uses Random.Shared for thread-safe random number generation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jwraats <3438726+jwraats@users.noreply.github.com>
---
.../Controllers/CreatureTamingController.cs | 71 +++++
.../DTOs/CreatureTaming/CreatureTamingDtos.cs | 21 ++
backend/WizardRPG.Api/Data/AppDbContext.cs | 20 ++
backend/WizardRPG.Api/Data/SeedData.cs | 22 ++
.../WizardRPG.Api/Models/CreatureTaming.cs | 40 +++
backend/WizardRPG.Api/Models/Player.cs | 1 +
backend/WizardRPG.Api/Program.cs | 1 +
.../Services/CreatureTamingService.cs | 242 ++++++++++++++++++
8 files changed, 418 insertions(+)
create mode 100644 backend/WizardRPG.Api/Controllers/CreatureTamingController.cs
create mode 100644 backend/WizardRPG.Api/DTOs/CreatureTaming/CreatureTamingDtos.cs
create mode 100644 backend/WizardRPG.Api/Models/CreatureTaming.cs
create mode 100644 backend/WizardRPG.Api/Services/CreatureTamingService.cs
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/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/Data/AppDbContext.cs b/backend/WizardRPG.Api/Data/AppDbContext.cs
index 6c7bc03..191ed6d 100644
--- a/backend/WizardRPG.Api/Data/AppDbContext.cs
+++ b/backend/WizardRPG.Api/Data/AppDbContext.cs
@@ -26,6 +26,8 @@ public AppDbContext(DbContextOptions options) : base(options) { }
public DbSet QuizQuestions => Set();
public DbSet QuizAttempts => Set();
public DbSet DungeonRuns => Set();
+ public DbSet Creatures => Set();
+ public DbSet PlayerCreatures => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -220,5 +222,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
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);
+ });
}
}
diff --git a/backend/WizardRPG.Api/Data/SeedData.cs b/backend/WizardRPG.Api/Data/SeedData.cs
index 9e6c5a0..e380434 100644
--- a/backend/WizardRPG.Api/Data/SeedData.cs
+++ b/backend/WizardRPG.Api/Data/SeedData.cs
@@ -146,5 +146,27 @@ public static void Seed(AppDbContext context)
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();
+ }
}
}
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/Player.cs b/backend/WizardRPG.Api/Models/Player.cs
index cb3b788..9e776c5 100644
--- a/backend/WizardRPG.Api/Models/Player.cs
+++ b/backend/WizardRPG.Api/Models/Player.cs
@@ -26,4 +26,5 @@ public class Player
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();
}
diff --git a/backend/WizardRPG.Api/Program.cs b/backend/WizardRPG.Api/Program.cs
index f654d74..d00dc7e 100644
--- a/backend/WizardRPG.Api/Program.cs
+++ b/backend/WizardRPG.Api/Program.cs
@@ -61,6 +61,7 @@
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);
+}
From cac4794bfa54c1d78ad5210dcb080f6cca467795 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:33:37 +0000
Subject: [PATCH 6/7] feat: add Whispering Walls text adventure mini-game
Add interactive fiction / choose-your-own-adventure mini-game with:
- StoryChapter, StoryChoice, PlayerStoryProgress models
- WhisperingWallsService with story arc management, choice processing,
item/stat requirement checks, and gold/xp rewards
- WhisperingWallsController with REST endpoints
- DTOs for all request/response types
- EF Core configuration with proper relationships
- Seed data for 'The Forbidden Corridor' story arc (7 chapters, 5 endings)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jwraats <3438726+jwraats@users.noreply.github.com>
---
.../Controllers/WhisperingWallsController.cs | 63 +++++
.../WhisperingWalls/WhisperingWallsDtos.cs | 22 ++
backend/WizardRPG.Api/Data/AppDbContext.cs | 35 +++
backend/WizardRPG.Api/Data/SeedData.cs | 110 +++++++++
backend/WizardRPG.Api/Models/Player.cs | 1 +
.../WizardRPG.Api/Models/WhisperingWalls.cs | 42 ++++
backend/WizardRPG.Api/Program.cs | 1 +
.../Services/WhisperingWallsService.cs | 228 ++++++++++++++++++
8 files changed, 502 insertions(+)
create mode 100644 backend/WizardRPG.Api/Controllers/WhisperingWallsController.cs
create mode 100644 backend/WizardRPG.Api/DTOs/WhisperingWalls/WhisperingWallsDtos.cs
create mode 100644 backend/WizardRPG.Api/Models/WhisperingWalls.cs
create mode 100644 backend/WizardRPG.Api/Services/WhisperingWallsService.cs
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/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/Data/AppDbContext.cs b/backend/WizardRPG.Api/Data/AppDbContext.cs
index 191ed6d..48fca74 100644
--- a/backend/WizardRPG.Api/Data/AppDbContext.cs
+++ b/backend/WizardRPG.Api/Data/AppDbContext.cs
@@ -28,6 +28,9 @@ public AppDbContext(DbContextOptions options) : base(options) { }
public DbSet DungeonRuns => Set();
public DbSet Creatures => Set();
public DbSet PlayerCreatures => Set();
+ public DbSet StoryChapters => Set();
+ public DbSet StoryChoices => Set();
+ public DbSet PlayerStoryProgress => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -240,5 +243,37 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.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);
+ });
}
}
diff --git a/backend/WizardRPG.Api/Data/SeedData.cs b/backend/WizardRPG.Api/Data/SeedData.cs
index e380434..49b1b56 100644
--- a/backend/WizardRPG.Api/Data/SeedData.cs
+++ b/backend/WizardRPG.Api/Data/SeedData.cs
@@ -168,5 +168,115 @@ public static void Seed(AppDbContext context)
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/Player.cs b/backend/WizardRPG.Api/Models/Player.cs
index 9e776c5..7ed8501 100644
--- a/backend/WizardRPG.Api/Models/Player.cs
+++ b/backend/WizardRPG.Api/Models/Player.cs
@@ -27,4 +27,5 @@ public class Player
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/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/Program.cs b/backend/WizardRPG.Api/Program.cs
index d00dc7e..09e4ac5 100644
--- a/backend/WizardRPG.Api/Program.cs
+++ b/backend/WizardRPG.Api/Program.cs
@@ -62,6 +62,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
// SignalR
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);
+ }
+}
From 147ad11849ad209d9b67bc757986cf66fc66bc22 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:49:31 +0000
Subject: [PATCH 7/7] Implement 6 new mini-games: Potion Brewing, Quiz, Dungeon
Crawler, Creature Taming, Whispering Walls, Wizard Chess - backend complete
Agent-Logs-Url: https://github.com/jwraats/Wizard-RPG/sessions/303adc6f-769d-408c-aa1d-ea28fea4f9f5
Co-authored-by: jwraats <3438726+jwraats@users.noreply.github.com>
---
.../Controllers/WizardChessController.cs | 61 +++
.../DTOs/WizardChess/WizardChessDtos.cs | 19 +
backend/WizardRPG.Api/Data/AppDbContext.cs | 20 +
backend/WizardRPG.Api/Models/WizardChess.cs | 28 ++
backend/WizardRPG.Api/Program.cs | 1 +
.../Services/WizardChessService.cs | 471 ++++++++++++++++++
6 files changed, 600 insertions(+)
create mode 100644 backend/WizardRPG.Api/Controllers/WizardChessController.cs
create mode 100644 backend/WizardRPG.Api/DTOs/WizardChess/WizardChessDtos.cs
create mode 100644 backend/WizardRPG.Api/Models/WizardChess.cs
create mode 100644 backend/WizardRPG.Api/Services/WizardChessService.cs
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/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 48fca74..a203b39 100644
--- a/backend/WizardRPG.Api/Data/AppDbContext.cs
+++ b/backend/WizardRPG.Api/Data/AppDbContext.cs
@@ -31,6 +31,7 @@ public AppDbContext(DbContextOptions options) : base(options) { }
public DbSet StoryChapters => Set();
public DbSet StoryChoices => Set();
public DbSet PlayerStoryProgress => Set();
+ public DbSet ChessMatches => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -275,5 +276,24 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.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/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 09e4ac5..4cb6664 100644
--- a/backend/WizardRPG.Api/Program.cs
+++ b/backend/WizardRPG.Api/Program.cs
@@ -63,6 +63,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
// SignalR
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