openthebox/tests/OpenTheBox.Tests/CraftingTests.cs
Samuel Bouchet c9f8a9566a Add adventure secret branches, Destiny finale, crafting system, and project docs
Integrate stats, resources, and cosmetics into adventures via conditional
branches gated by game state checks. Each of the 9 adventures now has a
secret branch that rewards exploration and encourages replay with subtle
hints on locked choices. The endgame box now triggers a Destiny adventure
that acknowledges all completed adventures and secret branches, with four
ending tiers culminating in an ultimate ending when all 9 secrets are found.

Also adds the crafting engine, CLAUDE.md and specifications.md for faster
onboarding.
2026-03-11 17:50:37 +01:00

711 lines
25 KiB
C#

using OpenTheBox.Core;
using OpenTheBox.Core.Crafting;
using OpenTheBox.Core.Enums;
using OpenTheBox.Core.Items;
using OpenTheBox.Data;
using OpenTheBox.Rendering.Panels;
using OpenTheBox.Simulation;
using OpenTheBox.Simulation.Events;
namespace OpenTheBox.Tests;
// ══════════════════════════════════════════════════════════════════════════
// CraftingEngine Tests
// ══════════════════════════════════════════════════════════════════════════
public class CraftingEngineTests
{
private static ContentRegistry CreateRegistryWithRecipe(
string recipeId = "refine_wood",
WorkstationType station = WorkstationType.Foundry,
string ingredientId = "material_wood_raw",
int ingredientQty = 2,
string resultId = "material_wood_refined",
int resultQty = 1)
{
var registry = new ContentRegistry();
registry.RegisterItem(new ItemDefinition(
ingredientId, $"item.{ingredientId}", ItemCategory.Material, ItemRarity.Common,
["Material"], MaterialType: MaterialType.Wood, MaterialForm: MaterialForm.Raw));
registry.RegisterItem(new ItemDefinition(
resultId, $"item.{resultId}", ItemCategory.Material, ItemRarity.Common,
["Material"], MaterialType: MaterialType.Wood, MaterialForm: MaterialForm.Refined));
registry.RegisterRecipe(new Recipe(
recipeId, $"recipe.{recipeId}", station,
[new RecipeIngredient(ingredientId, ingredientQty)],
new RecipeResult(resultId, resultQty)));
return registry;
}
private static GameState CreateState(bool craftingPanel = true, bool unlockFoundry = true)
{
var state = GameState.Create("Test", Locale.EN);
if (craftingPanel)
state.UnlockedUIFeatures.Add(UIFeature.CraftingPanel);
if (unlockFoundry)
state.UnlockedWorkstations.Add(WorkstationType.Foundry);
return state;
}
// ── FindCraftableRecipes ──────────────────────────────────────────
[Fact]
public void FindCraftable_NoCraftingPanel_ReturnsEmpty()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState(craftingPanel: false);
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var result = engine.FindCraftableRecipes(state, registry);
Assert.Empty(result);
}
[Fact]
public void FindCraftable_NoWorkstation_ReturnsEmpty()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState(unlockFoundry: false);
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var result = engine.FindCraftableRecipes(state, registry);
Assert.Empty(result);
}
[Fact]
public void FindCraftable_InsufficientIngredients_ReturnsEmpty()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 1)); // Need 2
var result = engine.FindCraftableRecipes(state, registry);
Assert.Empty(result);
}
[Fact]
public void FindCraftable_AllConditionsMet_ReturnsRecipe()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var result = engine.FindCraftableRecipes(state, registry);
Assert.Single(result);
Assert.Equal("refine_wood", result[0].Id);
}
[Fact]
public void FindCraftable_WorkstationBusy_ReturnsEmpty()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
// Add an in-progress job using Foundry
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "some_other",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var result = engine.FindCraftableRecipes(state, registry);
Assert.Empty(result);
}
[Fact]
public void FindCraftable_CompletedJobOnStation_AllowsNew()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
// A completed job doesn't block the station
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "some_other",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddHours(-2),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var result = engine.FindCraftableRecipes(state, registry);
Assert.Single(result);
}
// ── StartCraftingJob ──────────────────────────────────────────────
[Fact]
public void StartJob_ConsumesIngredients_CreatesJob()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var recipe = registry.Recipes["refine_wood"];
var events = engine.StartCraftingJob(recipe, state, registry);
// Ingredients consumed
Assert.Equal(0, state.CountItems("material_wood_raw"));
// Job created
Assert.Single(state.ActiveCraftingJobs);
Assert.Equal("refine_wood", state.ActiveCraftingJobs[0].RecipeId);
Assert.Equal(CraftingJobStatus.InProgress, state.ActiveCraftingJobs[0].Status);
// Events emitted
Assert.Contains(events, e => e is CraftingStartedEvent);
Assert.Contains(events, e => e is ItemConsumedEvent);
}
[Fact]
public void StartJob_DurationBasedOnResultRarity()
{
var engine = new CraftingEngine();
var registry = new ContentRegistry();
registry.RegisterItem(new ItemDefinition(
"input", "item.input", ItemCategory.Material, ItemRarity.Common, ["Material"]));
registry.RegisterItem(new ItemDefinition(
"output", "item.output", ItemCategory.Material, ItemRarity.Rare, ["Material"]));
registry.RegisterRecipe(new Recipe(
"test", "recipe.test", WorkstationType.Foundry,
[new RecipeIngredient("input", 1)],
new RecipeResult("output", 1)));
var state = CreateState();
state.AddItem(ItemInstance.Create("input", 1));
engine.StartCraftingJob(registry.Recipes["test"], state, registry);
Assert.Equal(TimeSpan.FromSeconds(60), state.ActiveCraftingJobs[0].Duration);
}
// ── TickJobs ──────────────────────────────────────────────────────
[Fact]
public void TickJobs_InProgressNotElapsed_NoEvent()
{
var engine = new CraftingEngine();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var events = engine.TickJobs(state);
Assert.Empty(events);
Assert.Equal(CraftingJobStatus.InProgress, state.ActiveCraftingJobs[0].Status);
}
[Fact]
public void TickJobs_InProgressElapsed_MarksCompleted()
{
var engine = new CraftingEngine();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.InProgress
});
var events = engine.TickJobs(state);
Assert.Single(events);
Assert.IsType<CraftingCompletedEvent>(events[0]);
Assert.Equal(CraftingJobStatus.Completed, state.ActiveCraftingJobs[0].Status);
}
[Fact]
public void TickJobs_AlreadyCompleted_NoDoubleEvent()
{
var engine = new CraftingEngine();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var events = engine.TickJobs(state);
Assert.Empty(events);
}
// ── CollectCompleted ──────────────────────────────────────────────
[Fact]
public void Collect_CompletedJob_CreatesResultItem()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var events = engine.CollectCompleted(state, registry);
// Result item added
Assert.True(state.HasItem("material_wood_refined"));
// Job removed
Assert.Empty(state.ActiveCraftingJobs);
// Events: ItemReceivedEvent + CraftingCollectedEvent
Assert.Contains(events, e => e is ItemReceivedEvent);
Assert.Contains(events, e => e is CraftingCollectedEvent);
}
[Fact]
public void Collect_NoCompletedJobs_NoEvents()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
var events = engine.CollectCompleted(state, registry);
Assert.Empty(events);
}
[Fact]
public void Collect_InProgressJob_NotCollected()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var events = engine.CollectCompleted(state, registry);
Assert.Empty(events);
Assert.Single(state.ActiveCraftingJobs);
}
// ── AutoCraftCheck ────────────────────────────────────────────────
[Fact]
public void AutoCraft_MatchingRecipeExists_StartsJob()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var events = engine.AutoCraftCheck(state, registry);
Assert.Contains(events, e => e is CraftingStartedEvent);
Assert.Single(state.ActiveCraftingJobs);
Assert.Equal(0, state.CountItems("material_wood_raw"));
}
[Fact]
public void AutoCraft_NoMatchingRecipe_NoAction()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
// No materials
var events = engine.AutoCraftCheck(state, registry);
Assert.Empty(events);
Assert.Empty(state.ActiveCraftingJobs);
}
// ── CraftingJob properties ────────────────────────────────────────
[Fact]
public void CraftingJob_IsComplete_TrueWhenTimeElapsed()
{
var job = new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.InProgress
};
Assert.True(job.IsComplete);
}
[Fact]
public void CraftingJob_IsComplete_FalseWhenNotElapsed()
{
var job = new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
};
Assert.False(job.IsComplete);
}
[Fact]
public void CraftingJob_ProgressPercent_CompletedIs100()
{
var job = new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
};
Assert.Equal(100.0, job.ProgressPercent);
}
// ── GetCraftDuration ──────────────────────────────────────────────
[Theory]
[InlineData(ItemRarity.Common, 15)]
[InlineData(ItemRarity.Uncommon, 30)]
[InlineData(ItemRarity.Rare, 60)]
[InlineData(ItemRarity.Epic, 90)]
[InlineData(ItemRarity.Legendary, 120)]
[InlineData(ItemRarity.Mythic, 180)]
public void GetCraftDuration_CorrectPerRarity(ItemRarity rarity, int expectedSeconds)
{
Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), CraftingEngine.GetCraftDuration(rarity));
}
}
// ══════════════════════════════════════════════════════════════════════════
// CraftingPanel Tests
// ══════════════════════════════════════════════════════════════════════════
public class CraftingPanelTests
{
[Fact]
public void Render_EmptyJobs_ShowsEmptyMessage()
{
var state = GameState.Create("Test", Locale.EN);
var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
Assert.NotEmpty(output);
// Should contain the empty workshop message
Assert.Contains("active", output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Render_InProgressJob_ShowsProgressBar()
{
var state = GameState.Create("Test", Locale.EN);
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
Assert.NotEmpty(output);
Assert.Contains("Foundry", output);
Assert.Contains("%", output);
}
[Fact]
public void Render_CompletedJob_ShowsDone()
{
var state = GameState.Create("Test", Locale.EN);
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
Assert.NotEmpty(output);
Assert.Contains("Foundry", output);
}
[Fact]
public void Render_WithRegistry_ResolvesRecipeName()
{
var registry = new ContentRegistry();
registry.RegisterRecipe(new Recipe(
"refine_wood", "recipe.refine_wood", WorkstationType.Foundry,
[new RecipeIngredient("material_wood_raw", 2)],
new RecipeResult("material_wood_refined", 1)));
var state = GameState.Create("Test", Locale.EN);
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var output = RenderHelper.RenderToString(CraftingPanel.Render(state, registry));
Assert.NotEmpty(output);
// Should show recipe name key (or localized version)
Assert.Contains("recipe.refine_wood", output);
}
[Fact]
public void Render_MixedJobs_DoesNotThrow()
{
var state = GameState.Create("Test", Locale.EN);
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "job1",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "job2",
Workstation = WorkstationType.Furnace,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
Assert.NotEmpty(output);
Assert.Contains("Foundry", output);
Assert.Contains("Furnace", output);
}
}
// ══════════════════════════════════════════════════════════════════════════
// Recipe Loading Tests
// ══════════════════════════════════════════════════════════════════════════
public class RecipeLoadingTests
{
private static string ContentPath(string file) =>
Path.Combine("content", "data", file);
[Fact]
public void LoadRecipes_AllRecipesLoaded()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
Assert.NotEmpty(registry.Recipes);
// recipes.json has 23 recipes (13 workstations)
Assert.True(registry.Recipes.Count >= 20, $"Expected >= 20 recipes, got {registry.Recipes.Count}");
}
[Fact]
public void LoadRecipes_AllRecipesHaveIngredients()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
foreach (var recipe in registry.Recipes.Values)
{
Assert.NotEmpty(recipe.Ingredients);
foreach (var ingredient in recipe.Ingredients)
{
Assert.NotNull(ingredient.ItemDefinitionId);
Assert.True(ingredient.Quantity > 0, $"Recipe {recipe.Id} has ingredient with qty <= 0");
}
}
}
[Fact]
public void LoadRecipes_AllRecipesHaveResult()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
foreach (var recipe in registry.Recipes.Values)
{
Assert.NotNull(recipe.Result);
Assert.NotNull(recipe.Result.ItemDefinitionId);
Assert.True(recipe.Result.Quantity > 0, $"Recipe {recipe.Id} has result with qty <= 0");
}
}
[Fact]
public void LoadRecipes_AllRecipesHaveValidWorkstation()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
var validStations = Enum.GetValues<WorkstationType>().ToHashSet();
foreach (var recipe in registry.Recipes.Values)
{
Assert.Contains(recipe.Workstation, validStations);
}
}
[Fact]
public void LoadRecipes_NullPath_Succeeds()
{
// Without recipes path, registry should still work
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"));
Assert.Empty(registry.Recipes);
}
[Fact]
public void LoadRecipes_AllBlueprintItemsExist()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
// Every blueprint item should have a WorkstationType set
var blueprints = registry.Items.Values
.Where(i => i.Tags.Contains("Blueprint"))
.ToList();
Assert.NotEmpty(blueprints);
foreach (var bp in blueprints)
{
Assert.True(bp.WorkstationType.HasValue, $"Blueprint {bp.Id} missing WorkstationType");
}
}
[Fact]
public void LoadRecipes_EachUsedWorkstation_HasBlueprint()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
var usedStations = registry.Recipes.Values
.Select(r => r.Workstation)
.Distinct()
.ToHashSet();
var blueprintStations = registry.Items.Values
.Where(i => i.WorkstationType.HasValue)
.Select(i => i.WorkstationType!.Value)
.ToHashSet();
foreach (var station in usedStations)
{
Assert.Contains(station, blueprintStations);
}
}
}
// ══════════════════════════════════════════════════════════════════════════
// GameSimulation Crafting Integration Tests
// ══════════════════════════════════════════════════════════════════════════
public class SimulationCraftingTests
{
private static string ContentPath(string file) =>
Path.Combine("content", "data", file);
[Fact]
public void OpenBox_WithMaterials_TriggerAutoCraft()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
var state = GameState.Create("Test", Locale.EN);
state.UnlockedUIFeatures.Add(UIFeature.CraftingPanel);
state.UnlockedWorkstations.Add(WorkstationType.Foundry);
// Pre-add materials so that after any box opening, auto-craft can check
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var engine = new CraftingEngine();
var events = engine.AutoCraftCheck(state, registry);
// Should start a crafting job for refine_wood
Assert.Contains(events, e => e is CraftingStartedEvent cs && cs.RecipeId == "refine_wood");
Assert.Single(state.ActiveCraftingJobs);
}
}