Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 25 additions & 21 deletions docs/plans/rules-engine-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,29 @@ Build a headless .NET 10 simulation engine that validates game design by running

### Current Sprint Goal

Complete Epic 4 (Crafting and Class Progression) to enable full crafting mechanics and class unlocking.
Epic 4 (Crafting and Class Progression) complete! Next: Epic 5 (Economy and Inventory) to enable trading and resource management.

### Epic Status Overview

| Epic | Title | Status | Progress | GitHub Issue |
| ---- | -------------------------------- | -------------- | -------- | ------------ |
| 1 | Project Foundation | ✅ Complete | 5/5 | #3 |
| 2 | Time and Simulation Loop | ✅ Complete | 6/6 | #4 |
| 3 | NPC Decision Making | ✅ Complete | 6/6 | #5 |
| 4 | Crafting and Class Progression | 🔄 In Progress | 7/8 | #6 |
| 5 | Economy and Inventory | ⏳ Planned | 0/6 | #7 |
| 6 | Contracts and Commitments | 🔄 Partial | 2/6 | #8 |
| 7 | Settlement and Guild | ⏳ Planned | 0/6 | #9 |
| 8 | Scenario Testing Framework | ⏳ Planned | 0/7 | #10 |
| 9 | Persistence | ⏳ Planned | 0/4 | #11 |
| 10 | Apprenticeship and Role Stacking | ⏳ Planned | 0/4 | #12 |
| 11 | Full Progression Scenario | ⏳ Planned | 0/5 | #13 |

**Total Progress**: 26/58 tasks complete (45%)
| Epic | Title | Status | Progress | GitHub Issue |
| ---- | -------------------------------- | ----------- | -------- | ------------ |
| 1 | Project Foundation | ✅ Complete | 5/5 | #3 |
| 2 | Time and Simulation Loop | ✅ Complete | 6/6 | #4 |
| 3 | NPC Decision Making | ✅ Complete | 6/6 | #5 |
| 4 | Crafting and Class Progression | ✅ Complete | 8/8 | #6 |
| 5 | Economy and Inventory | ⏳ Planned | 0/6 | #7 |
| 6 | Contracts and Commitments | 🔄 Partial | 2/6 | #8 |
| 7 | Settlement and Guild | ⏳ Planned | 0/6 | #9 |
| 8 | Scenario Testing Framework | ⏳ Planned | 0/7 | #10 |
| 9 | Persistence | ⏳ Planned | 0/4 | #11 |
| 10 | Apprenticeship and Role Stacking | ⏳ Planned | 0/4 | #12 |
| 11 | Full Progression Scenario | ⏳ Planned | 0/5 | #13 |

**Total Progress**: 27/58 tasks complete (47%)

### Recent Completions

- ✅ **2026-01-19**: Epic 4.8 - Masterwork → Master rank trigger (#39, PR #136)
- ✅ **2026-01-19**: Epic 4.7 - CraftingSystem (#38, PR #135)
- ✅ **2026-01-19**: Epic 4.6 - CraftingOutcomeRule (#37, PR #134)
- ✅ **2026-01-19**: Epic 4.5 - Define RecipeDefinition (#36, PR #127, commit 8148f26)
Expand All @@ -48,7 +49,8 @@ Complete Epic 4 (Crafting and Class Progression) to enable full crafting mechani

Priority order for remaining work:

1. **Epic 4.8** - #39: Implement Masterwork → Master rank trigger
1. **Epic 5** - #7: Economy and Inventory (0/6 tasks)
2. **Epic 6** - #8: Complete Contracts and Commitments (4/6 remaining)

## Key Design Decisions

Expand Down Expand Up @@ -250,11 +252,13 @@ _For implementation patterns, examine referenced code paths._
- ✅ Event emission with outcome details
- **Verified**: 8 tests pass, crafting produces items and awards XP, all 511 tests pass

**4.8 Implement Masterwork → Master rank trigger** _Issue #39_
**4.8 Implement Masterwork → Master rank trigger** _Issue #39, PR #136_

- Creating Masterwork immediately grants Master rank
- Emit event
- **Verify**: Masterwork creation triggers Master rank
- ✅ MasterRankAchievedEvent for tracking Master rank achievements
- ✅ Masterwork outcome immediately grants Master rank (level 10)
- ✅ Event emission with trigger reason
- ✅ Handles already-Master characters (no duplicate events)
- **Verified**: 4 tests pass, Masterwork triggers Master rank, all 515 tests pass

---

Expand Down
27 changes: 27 additions & 0 deletions src/FantasyRpgWorld.Core/Events/MasterRankAchievedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using FantasyRpgWorld.Core.Domain.ValueObjects;

namespace FantasyRpgWorld.Core.Events;

/// <summary>
/// Event emitted when a character achieves Master rank (level 10) in a class.
/// Triggered by creating a Masterwork item.
/// </summary>
public sealed record MasterRankAchievedEvent : IGameEvent
{
public GameTime OccurredAt { get; init; }
public CharacterId CharacterId { get; init; }
public ClassId ClassId { get; init; }
public string TriggerReason { get; init; }

public MasterRankAchievedEvent(
GameTime occurredAt,
CharacterId characterId,
ClassId classId,
string triggerReason)
{
OccurredAt = occurredAt;
CharacterId = characterId;
ClassId = classId;
TriggerReason = triggerReason ?? string.Empty;
}
}
36 changes: 36 additions & 0 deletions src/FantasyRpgWorld.Simulation/Systems/CraftingSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,42 @@ public SystemUpdateResult Update(WorldState worldState, GameTime currentTime)
updatedCharacter = updatedCharacter.WithClassProgress(classProgress);
}

// Handle Masterwork → Master rank trigger
if (outcomeResult.Outcome == CraftingOutcome.Masterwork)
{
const int MasterRankLevel = 10;
var classProgress = updatedCharacter.ClassProgress;

foreach (var skillReq in recipe.SkillRequirements)
{
if (classProgress.HasClass(skillReq.ClassId))
{
int currentLevel = classProgress.GetLevel(skillReq.ClassId);
if (currentLevel < MasterRankLevel)
{
// Set character to Master rank (level 10)
// Calculate XP needed to reach level 10
int xpToAdd = 0;
for (int level = currentLevel; level < MasterRankLevel; level++)
{
xpToAdd += 100 * level; // XP formula from ClassProgressCollection
}

classProgress = classProgress.AddXp(skillReq.ClassId, xpToAdd, out _);

// Emit Master rank achieved event
events.Add(new MasterRankAchievedEvent(
currentTime,
character.Id,
skillReq.ClassId,
"Masterwork"));
}
}
}

updatedCharacter = updatedCharacter.WithClassProgress(classProgress);
}

// Clear the active craft
updatedCharacter = updatedCharacter.WithActiveCraft(null);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
using FantasyRpgWorld.Core.Definitions;
using FantasyRpgWorld.Core.Domain.Components;
using FantasyRpgWorld.Core.Domain.Entities;
using FantasyRpgWorld.Core.Domain.ValueObjects;
using FantasyRpgWorld.Core.Domain.World;
using FantasyRpgWorld.Core.Events;
using FantasyRpgWorld.Simulation.Systems;

namespace FantasyRpgWorld.Simulation.Tests.Systems;

/// <summary>
/// Tests for Masterwork → Master rank trigger behavior.
/// Creating a Masterwork item immediately grants Master rank (level 10).
/// </summary>
public class CraftingSystemMasterworkTests
{
private const int MasterRankLevel = 10;

private static Character CreateTestCharacter(string name, ClassProgressCollection? classProgress = null)
{
var progress = classProgress ?? new ClassProgressCollection();
return Character.Create(
CharacterId.New(),
name,
race: "Human",
location: "Blacksmith Shop",
new AttributeSet(10, 10, 10, 10, 10, 10),
TraitSet.Neutral,
new NeedsState(100, 100, 100, 100, 100))
.WithClassProgress(progress);
}

private static RecipeDefinition CreateSimpleRecipe()
{
return new RecipeDefinition(
new RecipeId("iron_sword"),
"Iron Sword",
"A basic iron sword",
materialRequirements: new List<MaterialRequirement>
{
new(new MaterialId("iron_ingot"), 3)
},
skillRequirements: new List<SkillRequirement>
{
new(new ClassId("blacksmith"), 1)
},
baseQuality: 50,
craftingTimeMinutes: 120
);
}

[Fact]
public void MasterworkCraft_GrantsMasterRankToNovice()
{
// Arrange - Use fixed seed to guarantee Masterwork outcome
// With max modifiers (90 total), success rate is 95%, masterwork needs roll >= 96
// Seed 12 produces roll=99 which triggers Masterwork
var recipe = CreateSimpleRecipe();
var recipes = new List<RecipeDefinition> { recipe };
var system = new CraftingSystem(recipes, fixedRandom: new Random(12));

// Character with blacksmith class at level 1
var classProgress = new ClassProgressCollection().AddClass(new ClassId("blacksmith"));
var character = CreateTestCharacter("Thorgrim", classProgress);

// Add an active craft with very high modifiers to ensure Masterwork
var activeCraft = ActiveCraft.Start(
new CraftId("craft_001"),
recipe.Id,
character.Id,
startTime: new GameTime(1, 1, 1, 8, 0),
completionTime: new GameTime(1, 1, 1, 10, 0),
skillModifier: 50, // Maximum skill modifier
materialModifier: 20, // Maximum material modifier
toolModifier: 10, // Maximum tool modifier
workspaceModifier: 10 // Maximum workspace modifier
);
character = character.WithActiveCraft(activeCraft);

var worldState = new WorldState().AddCharacter(character);
var currentTime = new GameTime(1, 1, 1, 10, 0);

// Act
var result = system.Update(worldState, currentTime);

// Assert
var completedEvent = result.Events.OfType<CraftingCompletedEvent>().FirstOrDefault();
Assert.NotNull(completedEvent);
Assert.Equal(CraftingOutcome.Masterwork, completedEvent.Outcome);

// Character should now be at Master rank (level 10) in blacksmith
var updatedCharacter = result.UpdatedWorld.GetCharacter(character.Id);
Assert.NotNull(updatedCharacter);
var blacksmithLevel = updatedCharacter.ClassProgress.GetLevel(new ClassId("blacksmith"));
Assert.Equal(MasterRankLevel, blacksmithLevel);
}

[Fact]
public void MasterworkCraft_EmitsMasterRankAchievedEvent()
{
// Arrange - Use fixed seed to guarantee Masterwork outcome
var recipe = CreateSimpleRecipe();
var recipes = new List<RecipeDefinition> { recipe };
var system = new CraftingSystem(recipes, fixedRandom: new Random(12));

var classProgress = new ClassProgressCollection().AddClass(new ClassId("blacksmith"));
var character = CreateTestCharacter("Thorgrim", classProgress);

var activeCraft = ActiveCraft.Start(
new CraftId("craft_001"),
recipe.Id,
character.Id,
startTime: new GameTime(1, 1, 1, 8, 0),
completionTime: new GameTime(1, 1, 1, 10, 0),
skillModifier: 50,
materialModifier: 20,
toolModifier: 10,
workspaceModifier: 10
);
character = character.WithActiveCraft(activeCraft);

var worldState = new WorldState().AddCharacter(character);
var currentTime = new GameTime(1, 1, 1, 10, 0);

// Act
var result = system.Update(worldState, currentTime);

// Assert
var masterRankEvents = result.Events.OfType<MasterRankAchievedEvent>().ToList();
Assert.Single(masterRankEvents);

var evt = masterRankEvents[0];
Assert.Equal(character.Id, evt.CharacterId);
Assert.Equal(new ClassId("blacksmith"), evt.ClassId);
Assert.Equal("Masterwork", evt.TriggerReason);
}

[Fact]
public void MasterworkCraft_WhenAlreadyMaster_DoesNotEmitDuplicateEvent()
{
// Arrange - Character already at level 10
var recipe = CreateSimpleRecipe();
var recipes = new List<RecipeDefinition> { recipe };
var system = new CraftingSystem(recipes, fixedRandom: new Random(12));

// Character with blacksmith at level 10
var classProgress = new ClassProgressCollection().AddClass(new ClassId("blacksmith"));
// Level up to 10
for (int i = 1; i < MasterRankLevel; i++)
{
int xpNeeded = 100 * i; // Based on ClassProgressCollection formula
classProgress = classProgress.AddXp(new ClassId("blacksmith"), xpNeeded, out _);
}
var character = CreateTestCharacter("Thorgrim", classProgress);

var activeCraft = ActiveCraft.Start(
new CraftId("craft_001"),
recipe.Id,
character.Id,
startTime: new GameTime(1, 1, 1, 8, 0),
completionTime: new GameTime(1, 1, 1, 10, 0),
skillModifier: 50,
materialModifier: 20,
toolModifier: 10,
workspaceModifier: 10
);
character = character.WithActiveCraft(activeCraft);

var worldState = new WorldState().AddCharacter(character);
var currentTime = new GameTime(1, 1, 1, 10, 0);

// Act
var result = system.Update(worldState, currentTime);

// Assert
var masterRankEvents = result.Events.OfType<MasterRankAchievedEvent>().ToList();
Assert.Empty(masterRankEvents); // No event if already at or above Master rank
}

[Fact]
public void NonMasterworkCraft_DoesNotGrantMasterRank()
{
// Arrange - Use seed that produces non-Masterwork outcome
// Seed 2 produces roll < 96, so not Masterwork
var recipe = CreateSimpleRecipe();
var recipes = new List<RecipeDefinition> { recipe };
var system = new CraftingSystem(recipes, fixedRandom: new Random(2));

var classProgress = new ClassProgressCollection().AddClass(new ClassId("blacksmith"));
var character = CreateTestCharacter("Thorgrim", classProgress);

var activeCraft = ActiveCraft.Start(
new CraftId("craft_001"),
recipe.Id,
character.Id,
startTime: new GameTime(1, 1, 1, 8, 0),
completionTime: new GameTime(1, 1, 1, 10, 0),
skillModifier: 10,
materialModifier: 5,
toolModifier: 5,
workspaceModifier: 5
);
character = character.WithActiveCraft(activeCraft);

var worldState = new WorldState().AddCharacter(character);
var currentTime = new GameTime(1, 1, 1, 10, 0);

// Act
var result = system.Update(worldState, currentTime);

// Assert
var completedEvent = result.Events.OfType<CraftingCompletedEvent>().FirstOrDefault();
Assert.NotNull(completedEvent);
Assert.NotEqual(CraftingOutcome.Masterwork, completedEvent.Outcome);

var masterRankEvents = result.Events.OfType<MasterRankAchievedEvent>().ToList();
Assert.Empty(masterRankEvents); // No Master rank event for non-Masterwork
}
}