openthebox/tests/OpenTheBox.Tests/UnitTest1.cs
Samuel Bouchet 4c4d528187 Fix bugs, add endgame/completion features, adventure dedup, and comprehensive renderer tests
Bug fixes:
- Fix double "NEW FEATURE UNLOCKED" message and broken enum-to-key mapping for all UIFeatures
- Fix Spectre markup crash when opening inventory with colors unlocked (unescaped rarity brackets)
- Fix latent Spectre markup crash in ResourcePanel (unescaped bar brackets)
- Fix WeightedRandom.PickMultiple picking with replacement causing duplicate drops
- Fix double AddItem bug (BoxEngine + RenderEvents both adding to state)
- Add StatType and FontStyle fields to ItemDefinition (were in JSON but missing from C# record)

New features:
- Endgame box (Mythic) that appears when all 8 resources are discovered, contains Crown of Completion
- Completion percentage tracker as mid-game meta unlock (tier 3), shown in both renderers
- Adventure pool depletion: adventure sub-boxes with already-unlocked themes are removed from loot pool
- Global error handling with log file output (openthebox-error.log)
- Tiered meta progression: 5 sequential meta boxes replacing single box_meta

New tests (180 new, 228 total):
- 5 panel rendering test classes covering all enum values (Portrait, Resource, Stats, Inventory, Chat)
- RenderContext and RendererFactory logic tests
- SpectreRenderer output tests across 4 context configurations
- BasicRenderer output and input method tests
- Simulation state mutation and full-run completion tests
2026-03-11 09:34:30 +01:00

794 lines
32 KiB
C#

using System.Text.Json;
using OpenTheBox.Core;
using OpenTheBox.Core.Boxes;
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 ─────────────────────────────────────
[Fact]
public void FullRun_AllReachableContentIsObtained()
{
// Simulates an entire game playthrough by repeatedly opening boxes
// until all content reachable via box openings is unlocked.
// Uses only the simulation (zero I/O) to prove game completability.
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
var simulation = new GameSimulation(registry, new Random(42));
var state = GameState.Create("CompletionTest", Locale.EN);
// ── Compute the "reachable set" dynamically from item definitions ──
var allItems = registry.Items.Values.ToList();
// All MetaUnlock values that exist on items → expected UI features
var expectedUIFeatures = allItems
.Where(i => i.MetaUnlock.HasValue)
.Select(i => i.MetaUnlock!.Value)
.ToHashSet();
// All cosmetic definition IDs → expected cosmetics
var expectedCosmetics = allItems
.Where(i => i.CosmeticSlot.HasValue)
.Select(i => i.Id)
.ToHashSet();
// All AdventureTheme values that exist on items → expected adventures
var expectedAdventures = allItems
.Where(i => i.AdventureTheme.HasValue)
.Select(i => i.AdventureTheme!.Value)
.ToHashSet();
// All ResourceType values that exist on items → expected visible resources
var expectedResources = allItems
.Where(i => i.ResourceType.HasValue)
.Select(i => i.ResourceType!.Value)
.ToHashSet();
// All lore fragment definition IDs
var expectedLore = allItems
.Where(i => i.Category == ItemCategory.LoreFragment)
.Select(i => i.Id)
.ToHashSet();
// All StatType values that exist on items → expected visible stats
var expectedStats = allItems
.Where(i => i.StatType.HasValue)
.Select(i => i.StatType!.Value)
.ToHashSet();
// All FontStyle values that exist on items → expected fonts
var expectedFonts = allItems
.Where(i => i.FontStyle.HasValue)
.Select(i => i.FontStyle!.Value)
.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; // Game loop broke — will be caught by asserts
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
var events = simulation.ProcessAction(action, state);
// Track all received items
foreach (var evt in events.OfType<ItemReceivedEvent>())
{
seenDefinitionIds.Add(evt.Item.DefinitionId);
}
totalBoxesOpened++;
// Check if we've covered everything
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);
if (allUIFeatures && allCosmetics && allAdventures && allResources && allLore && allStats && allFonts)
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)}");
}
[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);
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.");
}
[Fact]
public void FullRun_PacingReport()
{
// Diagnostic test: outputs a pacing report showing when each piece
// of content is first unlocked. Not a pass/fail test — it always
// passes but prints progression milestones to the test output.
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
var simulation = new GameSimulation(registry, new Random(42));
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();
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;
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);
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));
if (curUI > prevUI)
milestones.Add((totalBoxesOpened, $"UI Feature {curUI}/{expectedUIFeatures.Count}: +{string.Join(", ", state.UnlockedUIFeatures.Where(f => expectedUIFeatures.Contains(f)).Except(milestones.Where(m => m.description.StartsWith("UI")).SelectMany(_ => Array.Empty<UIFeature>())))}"));
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;
complete = curUI == expectedUIFeatures.Count
&& curCos == expectedCosmetics.Count
&& curAdv == expectedAdventures.Count
&& curRes == expectedResources.Count
&& curLore == expectedLore.Count;
}
// Build the pacing report
var report = new System.Text.StringBuilder();
report.AppendLine();
report.AppendLine("╔══════════════════════════════════════════════════════╗");
report.AppendLine("║ PACING REPORT (seed=42) ║");
report.AppendLine("╠══════════════════════════════════════════════════════╣");
report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-32}║");
report.AppendLine($"║ Game completed: {(complete ? "YES" : "NO"),-36}║");
report.AppendLine("╠══════════════════════════════════════════════════════╣");
report.AppendLine("║ Box# │ Milestone ║");
report.AppendLine("╟────────┼────────────────────────────────────────────╢");
foreach (var (boxNum, desc) in milestones)
{
report.AppendLine($"║ {boxNum,5} │ {desc,-42} ║");
}
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;
report.AppendLine($"║ UI Features ({expectedUIFeatures.Count,2}) complete at box #{uiDoneAt,-17}║");
report.AppendLine($"║ Cosmetics ({expectedCosmetics.Count,2}) complete at box #{cosDoneAt,-17}║");
report.AppendLine($"║ Adventures ({expectedAdventures.Count,2}) complete at box #{advDoneAt,-17}║");
report.AppendLine($"║ Resources ({expectedResources.Count,2}) complete at box #{resDoneAt,-17}║");
report.AppendLine($"║ Lore ({expectedLore.Count,2}) complete at box #{loreDoneAt,-17}║");
report.AppendLine("╚══════════════════════════════════════════════════════╝");
// Output via ITestOutputHelper would be ideal, but Assert message works too
Assert.True(true, report.ToString());
// Also write to console for visibility in test runners
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)!;
}
}