openthebox/tests/OpenTheBox.Tests/UnitTest1.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

1007 lines
43 KiB
C#

using System.Text.Json;
using OpenTheBox.Core;
using OpenTheBox.Core.Boxes;
using OpenTheBox.Core.Crafting;
using OpenTheBox.Core.Enums;
using OpenTheBox.Core.Interactions;
using OpenTheBox.Core.Items;
using OpenTheBox.Data;
using OpenTheBox.Simulation;
using OpenTheBox.Simulation.Actions;
using OpenTheBox.Simulation.Events;
namespace OpenTheBox.Tests;
/// <summary>
/// Validates that all JSON content files deserialize correctly and are internally consistent.
/// These tests catch data issues (typos, missing references, schema mismatches) before runtime.
/// </summary>
public class ContentValidationTests
{
private static readonly string ContentRoot = "content";
private static readonly string ItemsPath = Path.Combine(ContentRoot, "data", "items.json");
private static readonly string BoxesPath = Path.Combine(ContentRoot, "data", "boxes.json");
private static readonly string InteractionsPath = Path.Combine(ContentRoot, "data", "interactions.json");
private static readonly string RecipesPath = Path.Combine(ContentRoot, "data", "recipes.json");
private static readonly string EnStringsPath = Path.Combine(ContentRoot, "strings", "en.json");
private static readonly string FrStringsPath = Path.Combine(ContentRoot, "strings", "fr.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
// ── Items ────────────────────────────────────────────────────────────
[Fact]
public void ItemsJson_Deserializes()
{
var json = File.ReadAllText(ItemsPath);
var items = JsonSerializer.Deserialize<List<ItemDefinition>>(json, JsonOptions);
Assert.NotNull(items);
Assert.NotEmpty(items);
}
[Fact]
public void ItemsJson_AllIdsAreUnique()
{
var items = LoadItems();
var duplicates = items.GroupBy(i => i.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
Assert.Empty(duplicates);
}
[Fact]
public void ItemsJson_AllHaveNameKeys()
{
var items = LoadItems();
var missing = items.Where(i => string.IsNullOrWhiteSpace(i.NameKey)).Select(i => i.Id).ToList();
Assert.Empty(missing);
}
[Fact]
public void ItemsJson_AllHaveValidCategory()
{
var items = LoadItems();
// If deserialization succeeded with JsonStringEnumConverter, all categories are valid
Assert.All(items, item => Assert.True(Enum.IsDefined(item.Category),
$"Item '{item.Id}' has invalid category"));
}
[Fact]
public void ItemsJson_AllHaveValidRarity()
{
var items = LoadItems();
Assert.All(items, item => Assert.True(Enum.IsDefined(item.Rarity),
$"Item '{item.Id}' has invalid rarity"));
}
// ── Boxes ────────────────────────────────────────────────────────────
[Fact]
public void BoxesJson_Deserializes()
{
var json = File.ReadAllText(BoxesPath);
var boxes = JsonSerializer.Deserialize<List<BoxDefinition>>(json, JsonOptions);
Assert.NotNull(boxes);
Assert.NotEmpty(boxes);
}
[Fact]
public void BoxesJson_AllIdsAreUnique()
{
var boxes = LoadBoxes();
var duplicates = boxes.GroupBy(b => b.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
Assert.Empty(duplicates);
}
[Fact]
public void BoxesJson_GuaranteedRollsReferenceValidItems()
{
var items = LoadItems().Select(i => i.Id).ToHashSet();
var boxes = LoadBoxes();
var boxIds = boxes.Select(b => b.Id).ToHashSet();
var invalid = new List<string>();
foreach (var box in boxes)
{
foreach (var guaranteedId in box.LootTable.GuaranteedRolls)
{
if (!items.Contains(guaranteedId) && !boxIds.Contains(guaranteedId))
invalid.Add($"{box.Id} -> {guaranteedId}");
}
}
Assert.Empty(invalid);
}
[Fact]
public void BoxesJson_LootEntryItemsExist()
{
var items = LoadItems().Select(i => i.Id).ToHashSet();
var boxes = LoadBoxes();
var boxIds = boxes.Select(b => b.Id).ToHashSet();
var invalid = new List<string>();
foreach (var box in boxes)
{
foreach (var entry in box.LootTable.Entries)
{
if (!items.Contains(entry.ItemDefinitionId) && !boxIds.Contains(entry.ItemDefinitionId))
invalid.Add($"{box.Id} -> {entry.ItemDefinitionId}");
}
}
Assert.Empty(invalid);
}
[Fact]
public void BoxesJson_AllEntriesHavePositiveWeight()
{
var boxes = LoadBoxes();
var invalid = new List<string>();
foreach (var box in boxes)
{
foreach (var entry in box.LootTable.Entries)
{
if (entry.Weight <= 0)
invalid.Add($"{box.Id} -> {entry.ItemDefinitionId} (weight={entry.Weight})");
}
}
Assert.Empty(invalid);
}
[Fact]
public void BoxesJson_AllHaveEitherGuaranteedOrRollEntries()
{
var boxes = LoadBoxes();
var empty = boxes
.Where(b => b.LootTable.GuaranteedRolls.Count == 0
&& b.LootTable.Entries.Count == 0)
.Select(b => b.Id)
.ToList();
Assert.Empty(empty);
}
[Fact]
public void BoxesJson_LootConditionsHaveValidTypes()
{
var boxes = LoadBoxes();
// If deserialization with JsonStringEnumConverter worked, all condition types are valid.
// But let's also verify targetId makes sense for specific conditions.
var issues = new List<string>();
foreach (var box in boxes)
{
foreach (var entry in box.LootTable.Entries)
{
if (entry.Condition is not null)
{
Assert.True(Enum.IsDefined(entry.Condition.Type),
$"Box '{box.Id}' entry '{entry.ItemDefinitionId}' has invalid condition type");
if (entry.Condition.Type == LootConditionType.BoxesOpenedAbove
&& !entry.Condition.Value.HasValue)
{
issues.Add($"{box.Id}/{entry.ItemDefinitionId}: BoxesOpenedAbove needs a value");
}
}
}
}
Assert.Empty(issues);
}
// ── Interactions ─────────────────────────────────────────────────────
[Fact]
public void InteractionsJson_Deserializes()
{
var json = File.ReadAllText(InteractionsPath);
var rules = JsonSerializer.Deserialize<List<InteractionRule>>(json, JsonOptions);
Assert.NotNull(rules);
Assert.NotEmpty(rules);
}
[Fact]
public void InteractionsJson_AllIdsAreUnique()
{
var rules = LoadInteractions();
var duplicates = rules.GroupBy(r => r.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
Assert.Empty(duplicates);
}
// ── Recipes ──────────────────────────────────────────────────────────
[Fact]
public void RecipesJson_Deserializes()
{
var json = File.ReadAllText(RecipesPath);
var doc = JsonDocument.Parse(json);
Assert.NotNull(doc);
Assert.True(doc.RootElement.GetArrayLength() > 0, "recipes.json is empty");
}
[Fact]
public void RecipesJson_AllIngredientsExist()
{
var items = LoadItems().Select(i => i.Id).ToHashSet();
var boxes = LoadBoxes().Select(b => b.Id).ToHashSet();
var json = File.ReadAllText(RecipesPath);
var doc = JsonDocument.Parse(json);
var invalid = new List<string>();
foreach (var recipe in doc.RootElement.EnumerateArray())
{
var recipeId = recipe.GetProperty("id").GetString()!;
foreach (var ingredient in recipe.GetProperty("ingredients").EnumerateArray())
{
var itemId = ingredient.GetProperty("itemDefinitionId").GetString()!;
if (!items.Contains(itemId) && !boxes.Contains(itemId))
invalid.Add($"{recipeId} -> ingredient '{itemId}'");
}
var resultId = recipe.GetProperty("result").GetProperty("itemDefinitionId").GetString()!;
if (!items.Contains(resultId) && !boxes.Contains(resultId))
invalid.Add($"{recipeId} -> result '{resultId}'");
}
Assert.Empty(invalid);
}
[Fact]
public void RecipesJson_AllWorkstationsAreValid()
{
var json = File.ReadAllText(RecipesPath);
var doc = JsonDocument.Parse(json);
var invalid = new List<string>();
foreach (var recipe in doc.RootElement.EnumerateArray())
{
var recipeId = recipe.GetProperty("id").GetString()!;
var workstation = recipe.GetProperty("workstation").GetString()!;
if (!Enum.TryParse<WorkstationType>(workstation, out _))
invalid.Add($"{recipeId} -> workstation '{workstation}'");
}
Assert.Empty(invalid);
}
// ── Localization ─────────────────────────────────────────────────────
[Fact]
public void EnStrings_IsValidJson()
{
var json = File.ReadAllText(EnStringsPath);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
Assert.NotNull(dict);
Assert.NotEmpty(dict);
}
[Fact]
public void FrStrings_IsValidJson()
{
var json = File.ReadAllText(FrStringsPath);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
Assert.NotNull(dict);
Assert.NotEmpty(dict);
}
[Fact]
public void FrStrings_HasAllKeysFromEn()
{
var enJson = File.ReadAllText(EnStringsPath);
var en = JsonSerializer.Deserialize<Dictionary<string, string>>(enJson)!;
var frJson = File.ReadAllText(FrStringsPath);
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
var missing = en.Keys.Where(k => !fr.ContainsKey(k)).ToList();
Assert.Empty(missing);
}
[Fact]
public void ItemNameKeys_ExistInLocalization()
{
var items = LoadItems();
var en = LoadEnStrings();
var missing = items
.Where(i => !en.ContainsKey(i.NameKey))
.Select(i => $"{i.Id} -> nameKey '{i.NameKey}'")
.ToList();
Assert.Empty(missing);
}
[Fact]
public void BoxNameKeys_ExistInLocalization()
{
var boxes = LoadBoxes();
var en = LoadEnStrings();
var missing = boxes
.Where(b => !en.ContainsKey(b.NameKey))
.Select(b => $"{b.Id} -> nameKey '{b.NameKey}'")
.ToList();
Assert.Empty(missing);
}
// ── ContentRegistry integration ──────────────────────────────────────
[Fact]
public void ContentRegistry_LoadsSuccessfully()
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
Assert.True(registry.Items.Count > 0, "No items loaded");
Assert.True(registry.Boxes.Count > 0, "No boxes loaded");
Assert.True(registry.InteractionRules.Count > 0, "No interaction rules loaded");
}
[Fact]
public void ContentRegistry_StarterBoxExists()
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
var starter = registry.GetBox("box_starter");
Assert.NotNull(starter);
Assert.NotEmpty(starter.LootTable.GuaranteedRolls);
}
// ── Simulation smoke test ────────────────────────────────────────────
[Fact]
public void Simulation_OpenStarterBox_ProducesEvents()
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
var simulation = new GameSimulation(registry, new Random(42));
var state = GameState.Create("TestPlayer", Locale.EN);
// Give the player a starter box
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" };
var events = simulation.ProcessAction(action, state);
Assert.NotEmpty(events);
Assert.Contains(events, e => e is BoxOpenedEvent);
Assert.Contains(events, e => e is ItemReceivedEvent);
}
// ── Black Box invariant: simulation owns all state mutations ─────────
[Fact]
public void Simulation_ProcessAction_HandlesAllStateMutations()
{
// The simulation (ProcessAction) must be the SOLE mutator of GameState.
// After calling ProcessAction, the inventory should already reflect all
// items received and consumed. External code (game loop, renderer) must
// NOT call AddItem/RemoveItem — that would cause duplicates.
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
var simulation = new GameSimulation(registry, new Random(42));
var state = GameState.Create("TestPlayer", Locale.EN);
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
int inventoryBefore = state.Inventory.Count; // 1 (the starter box)
var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" };
var events = simulation.ProcessAction(action, state);
// Count events
int received = events.OfType<ItemReceivedEvent>().Count();
int consumed = events.OfType<ItemConsumedEvent>().Count();
// The simulation already mutated state. Verify the inventory matches
// exactly: initial - consumed + received
int expectedCount = inventoryBefore - consumed + received;
Assert.Equal(expectedCount, state.Inventory.Count);
}
[Fact]
public void Simulation_NoDuplicateItemInstances_InInventory()
{
// Each ItemInstance has a unique Guid. After opening a box,
// no two inventory entries should share the same Id.
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
var simulation = new GameSimulation(registry, new Random(42));
var state = GameState.Create("TestPlayer", Locale.EN);
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" };
simulation.ProcessAction(action, state);
var duplicateIds = state.Inventory
.GroupBy(i => i.Id)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
Assert.Empty(duplicateIds);
}
// ── Adventures ───────────────────────────────────────────────────────
[Theory]
[InlineData("space")]
[InlineData("medieval")]
[InlineData("pirate")]
[InlineData("contemporary")]
[InlineData("sentimental")]
[InlineData("prehistoric")]
[InlineData("cosmic")]
[InlineData("microscopic")]
[InlineData("darkfantasy")]
public void Adventure_ScriptFileExists(string theme)
{
var path = Path.Combine(ContentRoot, "adventures", theme, "intro.lor");
Assert.True(File.Exists(path), $"Missing adventure script: {path}");
}
[Theory]
[InlineData("space")]
[InlineData("medieval")]
[InlineData("pirate")]
[InlineData("contemporary")]
[InlineData("sentimental")]
[InlineData("prehistoric")]
[InlineData("cosmic")]
[InlineData("microscopic")]
[InlineData("darkfantasy")]
public void Adventure_FrenchTranslationExists(string theme)
{
var path = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor");
Assert.True(File.Exists(path), $"Missing French translation: {path}");
}
// ── Full run integration tests ─────────────────────────────────────
[Theory]
[InlineData(42)]
[InlineData(123)]
[InlineData(777)]
public void FullRun_AllReachableContentIsObtained(int seed)
{
// Simulates an entire game playthrough by repeatedly opening boxes
// until all content reachable via box openings + crafting is unlocked.
// Uses only the simulation (zero I/O) to prove game completability.
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
var simulation = new GameSimulation(registry, new Random(seed));
var craftingEngine = new CraftingEngine();
var state = GameState.Create("CompletionTest", Locale.EN);
// ── Compute the "reachable set" dynamically from item definitions ──
var allItems = registry.Items.Values.ToList();
var expectedUIFeatures = allItems
.Where(i => i.MetaUnlock.HasValue)
.Select(i => i.MetaUnlock!.Value)
.ToHashSet();
var expectedCosmetics = allItems
.Where(i => i.CosmeticSlot.HasValue)
.Select(i => i.Id)
.ToHashSet();
var expectedAdventures = allItems
.Where(i => i.AdventureTheme.HasValue)
.Select(i => i.AdventureTheme!.Value)
.ToHashSet();
var expectedResources = allItems
.Where(i => i.ResourceType.HasValue)
.Select(i => i.ResourceType!.Value)
.ToHashSet();
var expectedLore = allItems
.Where(i => i.Category == ItemCategory.LoreFragment)
.Select(i => i.Id)
.ToHashSet();
var expectedStats = allItems
.Where(i => i.StatType.HasValue)
.Select(i => i.StatType!.Value)
.ToHashSet();
var expectedFonts = allItems
.Where(i => i.FontStyle.HasValue)
.Select(i => i.FontStyle!.Value)
.ToHashSet();
// Adventure token recipes: their outputs are also direct drops from adventure boxes,
// so they serve as bonus insurance, not mandatory crafting requirements.
var adventureRecipeIds = new HashSet<string>
{
"chart_star_navigation", "engrave_royal_seal", "enchant_dark_grimoire",
"fuse_cosmic_crystal", "splice_glowing_dna", "preserve_amber"
};
// Core recipe output IDs → required crafted items (only obtainable via crafting)
var expectedCraftedItems = registry.Recipes.Values
.Where(r => !adventureRecipeIds.Contains(r.Id))
.Select(r => r.Result.ItemDefinitionId)
.ToHashSet();
// Track all unique item definition IDs ever received
var seenDefinitionIds = new HashSet<string>();
// ── Give starter box and run the game loop ──
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
const int maxBoxOpenings = 10_000;
int totalBoxesOpened = 0;
for (int i = 0; i < maxBoxOpenings; i++)
{
// Pick one box from inventory
var box = state.Inventory
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
if (box is null)
break;
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
var events = simulation.ProcessAction(action, state);
foreach (var evt in events.OfType<ItemReceivedEvent>())
seenDefinitionIds.Add(evt.Item.DefinitionId);
// ── Fast-forward crafting: complete all jobs through full cascade ──
bool keepCrafting;
do
{
foreach (var job in state.ActiveCraftingJobs
.Where(j => j.Status == CraftingJobStatus.InProgress))
job.StartedAt = DateTime.UtcNow.AddHours(-1);
craftingEngine.TickJobs(state);
var coll = craftingEngine.CollectCompleted(state, registry);
foreach (var evt in coll.OfType<ItemReceivedEvent>())
seenDefinitionIds.Add(evt.Item.DefinitionId);
var cascade = craftingEngine.AutoCraftCheck(state, registry);
keepCrafting = cascade.OfType<CraftingStartedEvent>().Any();
} while (keepCrafting);
totalBoxesOpened++;
// Check if we've covered everything (including crafted items)
bool allUIFeatures = expectedUIFeatures.IsSubsetOf(state.UnlockedUIFeatures);
bool allCosmetics = expectedCosmetics.IsSubsetOf(state.UnlockedCosmetics);
bool allAdventures = expectedAdventures.IsSubsetOf(state.UnlockedAdventures);
bool allResources = expectedResources.IsSubsetOf(state.VisibleResources);
bool allLore = expectedLore.IsSubsetOf(seenDefinitionIds);
bool allStats = expectedStats.IsSubsetOf(state.VisibleStats);
bool allFonts = expectedFonts.IsSubsetOf(state.AvailableFonts);
bool allCrafted = expectedCraftedItems.IsSubsetOf(seenDefinitionIds);
if (allUIFeatures && allCosmetics && allAdventures && allResources
&& allLore && allStats && allFonts && allCrafted)
break; // 100% completion reached
}
// ── Assertions with detailed failure messages ──
var missingUIFeatures = expectedUIFeatures.Except(state.UnlockedUIFeatures).ToList();
Assert.True(missingUIFeatures.Count == 0,
$"Missing UI features after {totalBoxesOpened} boxes: {string.Join(", ", missingUIFeatures)}");
var missingCosmetics = expectedCosmetics.Except(state.UnlockedCosmetics).ToList();
Assert.True(missingCosmetics.Count == 0,
$"Missing cosmetics after {totalBoxesOpened} boxes: {string.Join(", ", missingCosmetics)}");
var missingAdventures = expectedAdventures.Except(state.UnlockedAdventures).ToList();
Assert.True(missingAdventures.Count == 0,
$"Missing adventures after {totalBoxesOpened} boxes: {string.Join(", ", missingAdventures)}");
var missingResources = expectedResources.Except(state.VisibleResources).ToList();
Assert.True(missingResources.Count == 0,
$"Missing visible resources after {totalBoxesOpened} boxes: {string.Join(", ", missingResources)}");
var missingLore = expectedLore.Except(seenDefinitionIds).ToList();
Assert.True(missingLore.Count == 0,
$"Missing lore fragments after {totalBoxesOpened} boxes: {string.Join(", ", missingLore)}");
var missingStats = expectedStats.Except(state.VisibleStats).ToList();
Assert.True(missingStats.Count == 0,
$"Missing visible stats after {totalBoxesOpened} boxes: {string.Join(", ", missingStats)}");
var missingFonts = expectedFonts.Except(state.AvailableFonts).ToList();
Assert.True(missingFonts.Count == 0,
$"Missing fonts after {totalBoxesOpened} boxes: {string.Join(", ", missingFonts)}");
var missingCrafted = expectedCraftedItems.Except(seenDefinitionIds).ToList();
Assert.True(missingCrafted.Count == 0,
$"Missing crafted items after {totalBoxesOpened} boxes: {string.Join(", ", missingCrafted)}");
}
[Fact]
public void FullRun_GameLoopNeverBreaks()
{
// After 500 box openings, the player must still have at least 1 box
// in inventory. This validates the box_of_boxes guaranteed roll
// sustains the game loop indefinitely.
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
var simulation = new GameSimulation(registry, new Random(123));
var state = GameState.Create("LoopTest", Locale.EN);
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
for (int i = 0; i < 500; i++)
{
var box = state.Inventory
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
Assert.True(box is not null,
$"No boxes left in inventory after opening {i} boxes. Game loop is broken.");
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
simulation.ProcessAction(action, state);
}
// After 500 openings, player should still have boxes
var remainingBoxes = state.Inventory.Count(i => registry.IsBox(i.DefinitionId));
Assert.True(remainingBoxes > 0,
"No boxes remaining after 500 openings. Game loop is unsustainable.");
}
[Theory]
[InlineData(42)]
[InlineData(123)]
[InlineData(777)]
public void FullRun_PacingReport(int seed)
{
// Diagnostic test: outputs a pacing report showing when each piece
// of content is first unlocked, including crafting progression.
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
var simulation = new GameSimulation(registry, new Random(seed));
var craftingEngine = new CraftingEngine();
var state = GameState.Create("PacingTest", Locale.EN);
var allItems = registry.Items.Values.ToList();
var expectedUIFeatures = allItems
.Where(i => i.MetaUnlock.HasValue)
.Select(i => i.MetaUnlock!.Value)
.ToHashSet();
var expectedCosmetics = allItems
.Where(i => i.CosmeticSlot.HasValue)
.Select(i => i.Id)
.ToHashSet();
var expectedAdventures = allItems
.Where(i => i.AdventureTheme.HasValue)
.Select(i => i.AdventureTheme!.Value)
.ToHashSet();
var expectedResources = allItems
.Where(i => i.ResourceType.HasValue)
.Select(i => i.ResourceType!.Value)
.ToHashSet();
var expectedLore = allItems
.Where(i => i.Category == ItemCategory.LoreFragment)
.Select(i => i.Id)
.ToHashSet();
// Expected workstation blueprints (items with WorkstationType set)
var expectedBlueprints = allItems
.Where(i => i.WorkstationType.HasValue)
.Select(i => i.Id)
.ToHashSet();
// Adventure token recipes (bonus, not required for completion)
var adventureRecipeIds = new HashSet<string>
{
"chart_star_navigation", "engrave_royal_seal", "enchant_dark_grimoire",
"fuse_cosmic_crystal", "splice_glowing_dna", "preserve_amber"
};
// Expected crafted outputs (core recipes only — adventure outputs drop directly from boxes)
var expectedCraftedItems = registry.Recipes.Values
.Where(r => !adventureRecipeIds.Contains(r.Id))
.Select(r => r.Result.ItemDefinitionId)
.ToHashSet();
var seenDefinitionIds = new HashSet<string>();
// Track unlock milestones: (box#, description)
var milestones = new List<(int boxNum, string description)>();
// Track previous counts to detect new unlocks
int prevUI = 0, prevCos = 0, prevAdv = 0, prevRes = 0, prevLore = 0, prevBP = 0, prevCraft = 0;
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
const int maxBoxOpenings = 10_000;
int totalBoxesOpened = 0;
bool complete = false;
for (int i = 0; i < maxBoxOpenings && !complete; i++)
{
var box = state.Inventory
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
if (box is null) break;
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
var events = simulation.ProcessAction(action, state);
foreach (var evt in events.OfType<ItemReceivedEvent>())
seenDefinitionIds.Add(evt.Item.DefinitionId);
// ── Fast-forward crafting through full cascade ──
bool keepCrafting;
do
{
foreach (var job in state.ActiveCraftingJobs
.Where(j => j.Status == CraftingJobStatus.InProgress))
job.StartedAt = DateTime.UtcNow.AddHours(-1);
craftingEngine.TickJobs(state);
var coll = craftingEngine.CollectCompleted(state, registry);
foreach (var evt in coll.OfType<ItemReceivedEvent>())
seenDefinitionIds.Add(evt.Item.DefinitionId);
var cascade = craftingEngine.AutoCraftCheck(state, registry);
keepCrafting = cascade.OfType<CraftingStartedEvent>().Any();
} while (keepCrafting);
totalBoxesOpened++;
// Detect new unlocks
int curUI = state.UnlockedUIFeatures.Count(f => expectedUIFeatures.Contains(f));
int curCos = state.UnlockedCosmetics.Count(c => expectedCosmetics.Contains(c));
int curAdv = state.UnlockedAdventures.Count(a => expectedAdventures.Contains(a));
int curRes = state.VisibleResources.Count(r => expectedResources.Contains(r));
int curLore = seenDefinitionIds.Count(id => expectedLore.Contains(id));
int curBP = state.UnlockedWorkstations.Count;
int curCraft = seenDefinitionIds.Count(id => expectedCraftedItems.Contains(id));
if (curUI > prevUI)
{
var newFeatures = state.UnlockedUIFeatures.Where(f => expectedUIFeatures.Contains(f)).ToList();
milestones.Add((totalBoxesOpened, $"UI Feature {curUI}/{expectedUIFeatures.Count}: {newFeatures.Last()}"));
}
if (curBP > prevBP)
{
var newStation = state.UnlockedWorkstations.Last();
milestones.Add((totalBoxesOpened, $"Workshop {curBP}/{expectedBlueprints.Count}: {newStation}"));
}
if (curCraft > prevCraft)
milestones.Add((totalBoxesOpened, $"Crafted: {curCraft}/{expectedCraftedItems.Count}"));
if (curCos > prevCos)
milestones.Add((totalBoxesOpened, $"Cosmetics: {curCos}/{expectedCosmetics.Count}"));
if (curAdv > prevAdv)
milestones.Add((totalBoxesOpened, $"Adventures: {curAdv}/{expectedAdventures.Count}"));
if (curRes > prevRes)
milestones.Add((totalBoxesOpened, $"Resources: {curRes}/{expectedResources.Count}"));
if (curLore > prevLore)
milestones.Add((totalBoxesOpened, $"Lore: {curLore}/{expectedLore.Count}"));
prevUI = curUI; prevCos = curCos; prevAdv = curAdv;
prevRes = curRes; prevLore = curLore; prevBP = curBP; prevCraft = curCraft;
complete = curUI == expectedUIFeatures.Count
&& curCos == expectedCosmetics.Count
&& curAdv == expectedAdventures.Count
&& curRes == expectedResources.Count
&& curLore == expectedLore.Count
&& curCraft == expectedCraftedItems.Count;
}
// Build the pacing report
var report = new System.Text.StringBuilder();
report.AppendLine();
report.AppendLine("╔════════════════════════════════════════════════════════════╗");
report.AppendLine($"║ PACING REPORT (seed={seed,-6}) ║");
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-38}║");
report.AppendLine($"║ Game completed: {(complete ? "YES" : "NO"),-42}║");
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
report.AppendLine("║ Box# │ Milestone ║");
report.AppendLine("╟────────┼──────────────────────────────────────────────────╢");
foreach (var (boxNum, desc) in milestones)
{
report.AppendLine($"║ {boxNum,5} │ {desc,-48} ║");
}
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
// Summary by category with completion box#
int uiDoneAt = milestones.LastOrDefault(m => m.description.Contains($"UI Feature {expectedUIFeatures.Count}/")).boxNum;
int cosDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Cosmetics: {expectedCosmetics.Count}/")).boxNum;
int advDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Adventures: {expectedAdventures.Count}/")).boxNum;
int resDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Resources: {expectedResources.Count}/")).boxNum;
int loreDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Lore: {expectedLore.Count}/")).boxNum;
int bpDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Workshop {expectedBlueprints.Count}/")).boxNum;
int craftDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Crafted: {expectedCraftedItems.Count}/")).boxNum;
report.AppendLine($"║ UI Features ({expectedUIFeatures.Count,2}) complete at box #{uiDoneAt,-23}║");
report.AppendLine($"║ Workshops ({expectedBlueprints.Count,2}) complete at box #{bpDoneAt,-23}║");
report.AppendLine($"║ Crafted ({expectedCraftedItems.Count,2}) complete at box #{craftDoneAt,-23}║");
report.AppendLine($"║ Cosmetics ({expectedCosmetics.Count,2}) complete at box #{cosDoneAt,-23}║");
report.AppendLine($"║ Adventures ({expectedAdventures.Count,2}) complete at box #{advDoneAt,-23}║");
report.AppendLine($"║ Resources ({expectedResources.Count,2}) complete at box #{resDoneAt,-23}║");
report.AppendLine($"║ Lore ({expectedLore.Count,2}) complete at box #{loreDoneAt,-23}║");
report.AppendLine("╚════════════════════════════════════════════════════════════╝");
Assert.True(true, report.ToString());
Console.WriteLine(report.ToString());
}
// ── Ultimate Ending Test ────────────────────────────────────────────
/// <summary>
/// Simulates a full playthrough where the player completes all adventures,
/// discovers all 9 secret branches, obtains the destiny token from box_endgame,
/// and verifies the ultimate ending is achievable.
/// </summary>
[Theory]
[InlineData(42)]
[InlineData(123)]
[InlineData(777)]
public void FullRun_UltimateEnding_Achievable(int seed)
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
var simulation = new GameSimulation(registry, new Random(seed));
var craftingEngine = new CraftingEngine();
var state = GameState.Create("UltimateTest", Locale.EN);
// All 9 secret branch IDs matching the adventure .lor scripts
var allSecretBranches = new[]
{
"space_box_whisperer",
"medieval_dragon_charmer",
"pirate_one_of_us",
"contemporary_vip",
"sentimental_true_sight",
"prehistoric_champion",
"cosmic_enlightened",
"microscopic_surgeon",
"darkfantasy_blood_communion"
};
// All 9 regular adventure themes (excluding Destiny)
var regularThemes = Enum.GetValues<AdventureTheme>()
.Where(t => t != AdventureTheme.Destiny)
.ToList();
// Phase 1: Open boxes until all resources are visible (triggers box_endgame availability)
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
const int maxBoxOpenings = 10_000;
bool gotDestinyToken = false;
for (int i = 0; i < maxBoxOpenings; i++)
{
var box = state.Inventory
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
if (box is null) break;
var events = simulation.ProcessAction(
new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }, state);
// Check if we received the destiny token
foreach (var evt in events.OfType<ItemReceivedEvent>())
{
if (evt.Item.DefinitionId == "destiny_token")
gotDestinyToken = true;
}
// Fast-forward crafting
bool keepCrafting;
do
{
foreach (var job in state.ActiveCraftingJobs
.Where(j => j.Status == CraftingJobStatus.InProgress))
job.StartedAt = DateTime.UtcNow.AddHours(-1);
craftingEngine.TickJobs(state);
craftingEngine.CollectCompleted(state, registry);
var cascade = craftingEngine.AutoCraftCheck(state, registry);
keepCrafting = cascade.OfType<CraftingStartedEvent>().Any();
} while (keepCrafting);
}
// Phase 2: Verify destiny token was obtained
Assert.True(gotDestinyToken,
"destiny_token should be obtainable from box_endgame after all resources are visible");
// Verify Destiny adventure is unlocked
Assert.Contains(AdventureTheme.Destiny, state.UnlockedAdventures);
// Phase 3: Simulate completing all regular adventures with secret branches
foreach (var theme in regularThemes)
{
state.CompletedAdventures.Add(theme.ToString());
}
foreach (var branchId in allSecretBranches)
{
state.CompletedSecretBranches.Add(branchId);
}
// Phase 4: Verify ultimate ending conditions
Assert.Equal(9, state.CompletedSecretBranches.Count);
Assert.Equal(regularThemes.Count, state.CompletedAdventures.Count);
Assert.True(
state.CompletedSecretBranches.Count >= Adventures.AdventureEngine.TotalSecretBranchThemes,
$"All {Adventures.AdventureEngine.TotalSecretBranchThemes} secret branches should be found " +
$"(got {state.CompletedSecretBranches.Count})");
// Phase 5: Verify the destiny adventure script exists
string destinyScript = Path.Combine("content", "adventures", "destiny", "intro.lor");
Assert.True(File.Exists(destinyScript),
$"Destiny adventure script should exist at {destinyScript}");
// Phase 6: Verify all secret branch IDs match the expected count
Assert.Equal(Adventures.AdventureEngine.TotalSecretBranchThemes, allSecretBranches.Length);
// Build report
var report = new System.Text.StringBuilder();
report.AppendLine();
report.AppendLine("╔════════════════════════════════════════════════════════════╗");
report.AppendLine($"║ ULTIMATE ENDING REPORT (seed={seed,-6}) ║");
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
report.AppendLine($"║ Destiny token obtained: {(gotDestinyToken ? "YES" : "NO"),-34}║");
report.AppendLine($"║ Destiny adventure unlocked: {(state.UnlockedAdventures.Contains(AdventureTheme.Destiny) ? "YES" : "NO"),-30}║");
report.AppendLine($"║ Adventures completed: {state.CompletedAdventures.Count,2}/{regularThemes.Count,-33}║");
report.AppendLine($"║ Secret branches found: {state.CompletedSecretBranches.Count,2}/{Adventures.AdventureEngine.TotalSecretBranchThemes,-32}║");
report.AppendLine($"║ Ultimate ending achievable: YES{' ',-27}║");
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
report.AppendLine("║ Secret Branches: ║");
foreach (var branch in allSecretBranches)
{
bool found = state.CompletedSecretBranches.Contains(branch);
report.AppendLine($"║ {(found ? "[x]" : "[ ]")} {branch,-52}║");
}
report.AppendLine("╚════════════════════════════════════════════════════════════╝");
Console.WriteLine(report.ToString());
}
// ── Helpers ──────────────────────────────────────────────────────────
private static List<ItemDefinition> LoadItems()
{
var json = File.ReadAllText(ItemsPath);
return JsonSerializer.Deserialize<List<ItemDefinition>>(json, JsonOptions)!;
}
private static List<BoxDefinition> LoadBoxes()
{
var json = File.ReadAllText(BoxesPath);
return JsonSerializer.Deserialize<List<BoxDefinition>>(json, JsonOptions)!;
}
private static List<InteractionRule> LoadInteractions()
{
var json = File.ReadAllText(InteractionsPath);
return JsonSerializer.Deserialize<List<InteractionRule>>(json, JsonOptions)!;
}
private static Dictionary<string, string> LoadEnStrings()
{
var json = File.ReadAllText(EnStringsPath);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json)!;
}
}