Skip to content

Commit e912260

Browse files
FortinbraCopilot
andcommitted
feat: implement spec #19 Enemy System
Enemy/EnemyAbility/LootTable/Encounter/EncounterEnemy entities. CombatStats owned value object. DropChance-based loot rolls via IDiceService. IEnemyService + IEncounterService. EnemyController + EncounterController. All Danny tests passing. Closes #19 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1dbecf0 commit e912260

30 files changed

Lines changed: 1096 additions & 5 deletions

.squad/agents/rory/history.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88

99
## Learnings
1010

11+
### 2026-04-10 — Spec #19 Enemy System
12+
13+
**Status:** ✅ Complete (Core + Infrastructure + API wired)
14+
15+
**What was done:**
16+
- Added Enemy/Encounter entities with CombatStats owned value object and loot tables
17+
- Implemented EnemyService + EncounterService with dice-based loot rolls
18+
- Added EnemyController + EncounterController endpoints and registered services
19+
20+
**Test Results:**
21+
- Enemy/Encounter Core + API tests passing
22+
1123
### 2026-04-09 — Spec #17 Image Management
1224

1325
**Status:** ✅ Complete (Core + Infrastructure + API wired)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using DungeonMaster.Core.Encounters;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.AspNetCore.Mvc;
4+
5+
namespace DungeonMaster.Api.Controllers;
6+
7+
/// <summary>Encounter management endpoints.</summary>
8+
[ApiController]
9+
[Route("api/encounters")]
10+
public sealed class EncounterController : ControllerBase
11+
{
12+
private readonly IEncounterService _encounterService;
13+
14+
/// <summary>Initializes a new instance of the <see cref="EncounterController"/> class.</summary>
15+
/// <param name="encounterService">Encounter service.</param>
16+
public EncounterController(IEncounterService encounterService) => _encounterService = encounterService;
17+
18+
/// <summary>Starts a new encounter.</summary>
19+
/// <param name="command">Start command.</param>
20+
/// <returns>The created encounter.</returns>
21+
[HttpPost]
22+
[Authorize(Policy = "RequireDungeonMaster")]
23+
public async Task<IActionResult> StartEncounter([FromBody] StartEncounterCommand command)
24+
{
25+
if (!ModelState.IsValid)
26+
{
27+
return BadRequest(ModelState);
28+
}
29+
30+
var encounter = await _encounterService.StartEncounterAsync(command.CampaignId, command.EnemyIds);
31+
return CreatedAtAction(nameof(GetActiveEncounter), new { campaignId = command.CampaignId }, encounter);
32+
}
33+
34+
/// <summary>Gets the active encounter for a campaign.</summary>
35+
/// <param name="campaignId">Campaign identifier.</param>
36+
/// <returns>The active encounter.</returns>
37+
[HttpGet("{campaignId:guid}/active")]
38+
[Authorize(Policy = "RequirePlayer")]
39+
public async Task<IActionResult> GetActiveEncounter(Guid campaignId)
40+
{
41+
var encounter = await _encounterService.GetActiveEncounterAsync(campaignId);
42+
return encounter is null ? NotFound() : Ok(encounter);
43+
}
44+
45+
/// <summary>Marks an encounter enemy defeated.</summary>
46+
/// <param name="id">Encounter identifier.</param>
47+
/// <param name="enemyId">Enemy identifier.</param>
48+
/// <returns>No content.</returns>
49+
[HttpPost("{id:guid}/defeat-enemy/{enemyId:guid}")]
50+
[Authorize(Policy = "RequireDungeonMaster")]
51+
public async Task<IActionResult> DefeatEnemy(Guid id, Guid enemyId)
52+
{
53+
await _encounterService.DefeatEnemyAsync(id, enemyId);
54+
return NoContent();
55+
}
56+
57+
/// <summary>Ends an encounter.</summary>
58+
/// <param name="id">Encounter identifier.</param>
59+
/// <returns>No content.</returns>
60+
[HttpPost("{id:guid}/end")]
61+
[Authorize(Policy = "RequireDungeonMaster")]
62+
public async Task<IActionResult> EndEncounter(Guid id)
63+
{
64+
await _encounterService.EndEncounterAsync(id);
65+
return NoContent();
66+
}
67+
68+
/// <summary>Rolls loot for an encounter.</summary>
69+
/// <param name="id">Encounter identifier.</param>
70+
/// <returns>The loot results.</returns>
71+
[HttpGet("{id:guid}/loot")]
72+
[Authorize(Policy = "RequireDungeonMaster")]
73+
public async Task<IActionResult> RollLoot(Guid id)
74+
{
75+
var loot = await _encounterService.RollLootAsync(id);
76+
return Ok(loot.ToList());
77+
}
78+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using DungeonMaster.Core.Enemies;
2+
using DungeonMaster.Core.Entities;
3+
using Microsoft.AspNetCore.Authorization;
4+
using Microsoft.AspNetCore.Mvc;
5+
6+
namespace DungeonMaster.Api.Controllers;
7+
8+
/// <summary>Enemy catalog endpoints.</summary>
9+
[ApiController]
10+
[Route("api/enemies")]
11+
public sealed class EnemyController : ControllerBase
12+
{
13+
private readonly IEnemyService _enemyService;
14+
15+
/// <summary>Initializes a new instance of the <see cref="EnemyController"/> class.</summary>
16+
/// <param name="enemyService">Enemy service.</param>
17+
public EnemyController(IEnemyService enemyService) => _enemyService = enemyService;
18+
19+
/// <summary>Gets all enemies.</summary>
20+
/// <returns>The enemy list.</returns>
21+
[HttpGet]
22+
[Authorize(Policy = "RequirePlayer")]
23+
public async Task<IActionResult> GetEnemies()
24+
{
25+
var enemies = await _enemyService.GetEnemiesAsync();
26+
return Ok(enemies.ToList());
27+
}
28+
29+
/// <summary>Gets an enemy by identifier.</summary>
30+
/// <param name="id">Enemy identifier.</param>
31+
/// <returns>The enemy.</returns>
32+
[HttpGet("{id:guid}")]
33+
[Authorize(Policy = "RequirePlayer")]
34+
public async Task<IActionResult> GetEnemy(Guid id)
35+
{
36+
var enemy = await _enemyService.GetEnemyAsync(id);
37+
return enemy is null ? NotFound() : Ok(enemy);
38+
}
39+
40+
/// <summary>Creates a new enemy.</summary>
41+
/// <param name="command">Creation command.</param>
42+
/// <returns>The created enemy.</returns>
43+
[HttpPost]
44+
[Authorize(Policy = "RequireDungeonMaster")]
45+
public async Task<IActionResult> CreateEnemy([FromBody] CreateEnemyCommand command)
46+
{
47+
if (!ModelState.IsValid)
48+
{
49+
return BadRequest(ModelState);
50+
}
51+
52+
var created = await _enemyService.CreateEnemyAsync(command);
53+
return CreatedAtAction(nameof(GetEnemy), new { id = created.Id }, created);
54+
}
55+
56+
/// <summary>Updates an enemy.</summary>
57+
/// <param name="id">Enemy identifier.</param>
58+
/// <param name="enemy">Enemy payload.</param>
59+
/// <returns>No content.</returns>
60+
[HttpPut("{id:guid}")]
61+
[Authorize(Policy = "RequireDungeonMaster")]
62+
public async Task<IActionResult> UpdateEnemy(Guid id, [FromBody] Enemy enemy)
63+
{
64+
if (!ModelState.IsValid)
65+
{
66+
return BadRequest(ModelState);
67+
}
68+
69+
enemy.Id = id;
70+
await _enemyService.UpdateEnemyAsync(enemy);
71+
return NoContent();
72+
}
73+
74+
/// <summary>Deletes an enemy.</summary>
75+
/// <param name="id">Enemy identifier.</param>
76+
/// <returns>No content.</returns>
77+
[HttpDelete("{id:guid}")]
78+
[Authorize(Policy = "RequireDungeonMaster")]
79+
public async Task<IActionResult> DeleteEnemy(Guid id)
80+
{
81+
await _enemyService.DeleteEnemyAsync(id);
82+
return NoContent();
83+
}
84+
85+
/// <summary>Gets the loot table for an enemy.</summary>
86+
/// <param name="id">Enemy identifier.</param>
87+
/// <returns>The loot table.</returns>
88+
[HttpGet("{id:guid}/loot-table")]
89+
[Authorize(Policy = "RequireDungeonMaster")]
90+
public async Task<IActionResult> GetLootTable(Guid id)
91+
{
92+
var enemy = await _enemyService.GetEnemyAsync(id);
93+
return enemy?.LootTable is null ? NotFound() : Ok(enemy.LootTable);
94+
}
95+
96+
/// <summary>Updates the loot table for an enemy.</summary>
97+
/// <param name="id">Enemy identifier.</param>
98+
/// <param name="entries">Loot entries.</param>
99+
/// <returns>The updated enemy.</returns>
100+
[HttpPut("{id:guid}/loot-table")]
101+
[Authorize(Policy = "RequireDungeonMaster")]
102+
public async Task<IActionResult> UpdateLootTable(Guid id, [FromBody] List<LootEntry> entries)
103+
{
104+
if (!ModelState.IsValid)
105+
{
106+
return BadRequest(ModelState);
107+
}
108+
109+
var updated = await _enemyService.UpdateLootTableAsync(id, entries);
110+
return updated is null ? NotFound() : Ok(updated);
111+
}
112+
}

src/DungeonMaster.Api/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using DungeonMaster.Api.Extensions;
22
using DungeonMaster.Core.Campaigns;
3+
using DungeonMaster.Core.Encounters;
4+
using DungeonMaster.Core.Enemies;
35
using DungeonMaster.Core.Items;
46
using DungeonMaster.Core.Rulesets;
57
using DungeonMaster.Core.WorldBuilding;
@@ -11,6 +13,8 @@
1113
builder.Services.AddAppAuthorization();
1214
builder.Services.AddControllers();
1315
builder.Services.AddScoped<ICampaignService, CampaignService>();
16+
builder.Services.AddScoped<IEnemyService, EnemyService>();
17+
builder.Services.AddScoped<IEncounterService, EncounterService>();
1418
builder.Services.AddScoped<IItemService, ItemService>();
1519
builder.Services.AddScoped<IRulesetService, RulesetService>();
1620
builder.Services.AddScoped<IWorldService, WorldService>();

0 commit comments

Comments
 (0)