openthebox/tests/OpenTheBox.Tests/UnitTest1.cs
Samuel Bouchet 71a35cd19d Improve inventory UX: localized names, smart sorting, icons, box details, lore progress
- Resolve box names via BoxDefinition.NameKey instead of showing raw IDs
- Reorder inventory categories: Box → Consumable → Lore → Cosmetic → Material → Meta
- Replace English category/rarity text with emoji icons and localized rarity labels
- Show box description in detail panel when selecting a box item
- Add lore collection progress counter (N/10) in lore fragment detail panel
- Add FR rarity translations (Commun, Peu commun, Rare, Épique, Légendaire, Mythique)
2026-03-13 23:36:50 +01:00

1698 lines
72 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.Localization;
using OpenTheBox.Rendering;
using OpenTheBox.Rendering.Panels;
using OpenTheBox.Simulation;
using OpenTheBox.Simulation.Actions;
using OpenTheBox.Simulation.Events;
using Spectre.Console;
using Spectre.Console.Rendering;
using Loreline;
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);
}
[Fact]
public void BoxDescriptionKeys_ExistInLocalization()
{
var boxes = LoadBoxes();
var en = LoadEnStrings();
var missing = boxes
.Where(b => !string.IsNullOrEmpty(b.DescriptionKey) && !en.ContainsKey(b.DescriptionKey))
.Select(b => $"{b.Id} -> descriptionKey '{b.DescriptionKey}'")
.ToList();
Assert.Empty(missing);
}
[Fact]
public void ItemNameKeys_ExistInFrLocalization()
{
var items = LoadItems();
var frJson = File.ReadAllText(FrStringsPath);
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
var missing = items
.Where(i => !fr.ContainsKey(i.NameKey))
.Select(i => $"{i.Id} -> nameKey '{i.NameKey}'")
.ToList();
Assert.Empty(missing);
}
[Fact]
public void BoxNameKeys_ExistInFrLocalization()
{
var boxes = LoadBoxes();
var frJson = File.ReadAllText(FrStringsPath);
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
var missing = boxes
.Where(b => !fr.ContainsKey(b.NameKey))
.Select(b => $"{b.Id} -> nameKey '{b.NameKey}'")
.ToList();
Assert.Empty(missing);
}
[Fact]
public void BoxDescriptionKeys_ExistInFrLocalization()
{
var boxes = LoadBoxes();
var frJson = File.ReadAllText(FrStringsPath);
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
var missing = boxes
.Where(b => !string.IsNullOrEmpty(b.DescriptionKey) && !fr.ContainsKey(b.DescriptionKey))
.Select(b => $"{b.Id} -> descriptionKey '{b.DescriptionKey}'")
.ToList();
Assert.Empty(missing);
}
[Fact]
public void AllUIFeatures_HaveLocalizationKeys()
{
var en = LoadEnStrings();
// Map of UIFeature -> expected localization key
var featureKeys = new Dictionary<UIFeature, string>
{
[UIFeature.TextColors] = "meta.colors",
[UIFeature.ExtendedColors] = "meta.extended_colors",
[UIFeature.ArrowKeySelection] = "meta.arrows",
[UIFeature.InventoryPanel] = "meta.inventory",
[UIFeature.ResourcePanel] = "meta.resources",
[UIFeature.StatsPanel] = "meta.stats",
[UIFeature.PortraitPanel] = "meta.portrait",
[UIFeature.ChatPanel] = "meta.chat",
[UIFeature.FullLayout] = "meta.layout",
[UIFeature.KeyboardShortcuts] = "meta.shortcuts",
[UIFeature.BoxAnimation] = "meta.animation",
[UIFeature.CraftingPanel] = "meta.crafting",
[UIFeature.CompletionTracker] = "meta.completion",
[UIFeature.AutoSave] = "meta.autosave",
};
var missing = featureKeys
.Where(kv => !en.ContainsKey(kv.Value))
.Select(kv => $"{kv.Key} -> '{kv.Value}'")
.ToList();
Assert.Empty(missing);
// Also verify every UIFeature enum value has a mapping
var allFeatures = Enum.GetValues<UIFeature>();
var unmapped = allFeatures.Where(f => !featureKeys.ContainsKey(f)).ToList();
Assert.Empty(unmapped);
}
[Fact]
public void AllMetaUnlockItems_ReferenceValidUIFeatures()
{
var items = LoadItems();
var metaItems = items.Where(i => i.MetaUnlock.HasValue).ToList();
// Every meta item's MetaUnlock value should be a valid UIFeature
// (this is guaranteed by deserialization, but let's verify the round-trip)
Assert.All(metaItems, item =>
Assert.True(Enum.IsDefined(item.MetaUnlock!.Value),
$"Item {item.Id} has invalid metaUnlock value"));
}
[Fact]
public void CosmeticSlots_HaveLocalizationKeys()
{
var en = LoadEnStrings();
var items = LoadItems();
// Every CosmeticSlot used in items should have a cosmetic.slot.{slot} key
var cosmeticSlots = items
.Where(i => i.CosmeticSlot.HasValue)
.Select(i => i.CosmeticSlot!.Value)
.Distinct()
.ToList();
var missingSlotKeys = cosmeticSlots
.Select(s => $"cosmetic.slot.{s.ToString().ToLower()}")
.Where(k => !en.ContainsKey(k))
.ToList();
Assert.Empty(missingSlotKeys);
}
[Fact]
public void CosmeticItems_CanBeResolvedBySlotAndValue()
{
// Verifies the lookup pattern used in ChangeAppearance():
// For every cosmetic item, a lookup by (CosmeticSlot, CosmeticValue) must find the item
var items = LoadItems();
var cosmeticItems = items
.Where(i => i.CosmeticSlot.HasValue && i.CosmeticValue is not null)
.ToList();
// Check for duplicate (slot, value) pairs which would cause ambiguous lookups
var duplicates = cosmeticItems
.GroupBy(i => (i.CosmeticSlot, Value: i.CosmeticValue!.ToLower()))
.Where(g => g.Count() > 1)
.Select(g => $"({g.Key.CosmeticSlot}, {g.Key.Value}): [{string.Join(", ", g.Select(i => i.Id))}]")
.ToList();
Assert.Empty(duplicates);
}
[Fact]
public void EnAndFr_HaveIdenticalKeysets()
{
var en = LoadEnStrings();
var frJson = File.ReadAllText(FrStringsPath);
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
var onlyInEn = en.Keys.Where(k => !fr.ContainsKey(k)).ToList();
var onlyInFr = fr.Keys.Where(k => !en.ContainsKey(k)).ToList();
Assert.Empty(onlyInEn);
Assert.Empty(onlyInFr);
}
// ── 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")]
[InlineData("destiny")]
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}");
}
// ── Adventure script parsing ───────────────────────────────────────
[Theory]
[InlineData("space")]
[InlineData("medieval")]
[InlineData("pirate")]
[InlineData("contemporary")]
[InlineData("sentimental")]
[InlineData("prehistoric")]
[InlineData("cosmic")]
[InlineData("microscopic")]
[InlineData("darkfantasy")]
[InlineData("destiny")]
public void Adventure_ScriptParsesWithoutError(string theme)
{
var path = Path.Combine(ContentRoot, "adventures", theme, "intro.lor");
Assert.True(File.Exists(path), $"Missing adventure script: {path}");
string content = File.ReadAllText(path);
var exception = Record.Exception(() =>
{
Script script = Engine.Parse(
content,
path,
(importPath, callback) =>
{
string dir = Path.GetDirectoryName(path) ?? ".";
string fullPath = Path.Combine(dir, importPath);
callback(File.Exists(fullPath) ? File.ReadAllText(fullPath) : string.Empty);
});
Assert.NotNull(script);
});
Assert.Null(exception);
}
[Theory]
[InlineData("space")]
[InlineData("medieval")]
[InlineData("pirate")]
[InlineData("contemporary")]
[InlineData("sentimental")]
[InlineData("prehistoric")]
[InlineData("cosmic")]
[InlineData("microscopic")]
[InlineData("darkfantasy")]
[InlineData("destiny")]
public void Adventure_FrenchTranslationParsesWithoutError(string theme)
{
var path = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor");
Assert.True(File.Exists(path), $"Missing French translation: {path}");
string content = File.ReadAllText(path);
var exception = Record.Exception(() =>
{
Script script = Engine.Parse(content);
Assert.NotNull(script);
});
Assert.Null(exception);
}
// ── French translation tag coverage ────────────────────────────────
[Theory]
[InlineData("space")]
[InlineData("medieval")]
[InlineData("pirate")]
[InlineData("contemporary")]
[InlineData("sentimental")]
[InlineData("prehistoric")]
[InlineData("cosmic")]
[InlineData("microscopic")]
[InlineData("darkfantasy")]
[InlineData("destiny")]
public void Adventure_FrenchTranslationCoversAllTags(string theme)
{
var enPath = Path.Combine(ContentRoot, "adventures", theme, "intro.lor");
var frPath = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor");
Assert.True(File.Exists(enPath), $"Missing EN script: {enPath}");
Assert.True(File.Exists(frPath), $"Missing FR translation: {frPath}");
// Extract tags from EN file: tags appear at end of lines as #tag-name
var enContent = File.ReadAllLines(enPath);
var tagRegex = new System.Text.RegularExpressions.Regex(@"#([a-z][a-z0-9_-]*)\b");
var enTags = new HashSet<string>();
foreach (var line in enContent)
{
var trimmed = line.TrimStart();
if (trimmed.StartsWith("//")) continue;
foreach (System.Text.RegularExpressions.Match m in tagRegex.Matches(line))
{
enTags.Add(m.Groups[1].Value);
}
}
// Extract tags from FR file: tags appear at start of lines as #tag-name
var frContent = File.ReadAllLines(frPath);
var frTags = new HashSet<string>();
foreach (var line in frContent)
{
var trimmed = line.TrimStart();
if (trimmed.StartsWith("#"))
{
var match = tagRegex.Match(trimmed);
if (match.Success)
frTags.Add(match.Groups[1].Value);
}
}
var missingInFr = enTags.Except(frTags).ToList();
Assert.True(
missingInFr.Count == 0,
$"[{theme}] {missingInFr.Count} EN tag(s) missing in FR translation:\n " +
string.Join("\n ", missingInFr.OrderBy(t => t)));
}
// ── 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();
// 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 allCrafted = expectedCraftedItems.IsSubsetOf(seenDefinitionIds);
if (allUIFeatures && allCosmetics && allAdventures && allResources
&& allLore && allStats && 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 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());
}
// ── Save Snapshot Generator ────────────────────────────────────────
/// <summary>
/// Generates save files at key progression milestones for visual testing.
/// Snapshots are saved to saves/ as snapshot_1.otb through snapshot_9.otb.
/// Load them in-game with Ctrl+1..9 for instant visual testing.
///
/// Run with: dotnet test --filter "GenerateSaveSnapshots" --logger "console;verbosity=detailed"
/// </summary>
[Fact]
public void GenerateSaveSnapshots()
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
var simulation = new GameSimulation(registry, new Random(42));
var craftingEngine = new CraftingEngine();
var state = GameState.Create("SnapshotPlayer", Locale.FR);
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
// Define snapshot points: (box count threshold, slot name, description)
var snapshotDefs = new (int boxes, string slot, string desc)[]
{
(10, "snapshot_1", "Very early game"),
(30, "snapshot_2", "First meta unlocks"),
(75, "snapshot_3", "Several UI panels"),
(150, "snapshot_4", "Adventures + cosmetics"),
(300, "snapshot_5", "Crafting + workshops"),
(500, "snapshot_6", "Most features"),
(750, "snapshot_7", "Near completion"),
(1000, "snapshot_8", "Endgame"),
(2000, "snapshot_9", "Post-endgame"),
};
int nextSnapshotIdx = 0;
int totalBoxesOpened = 0;
int maxTarget = snapshotDefs[^1].boxes;
var saveManager = new Persistence.SaveManager();
var report = new System.Text.StringBuilder();
report.AppendLine();
report.AppendLine("╔════════════════════════════════════════════════════════════╗");
report.AppendLine("║ SAVE SNAPSHOT GENERATOR ║");
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
for (int i = 0; i < 15_000 && totalBoxesOpened < maxTarget; 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 };
simulation.ProcessAction(action, state);
// 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);
totalBoxesOpened++;
// Check if we've hit a snapshot point
if (nextSnapshotIdx < snapshotDefs.Length &&
totalBoxesOpened >= snapshotDefs[nextSnapshotIdx].boxes)
{
var (_, slot, desc) = snapshotDefs[nextSnapshotIdx];
state.TotalBoxesOpened = totalBoxesOpened;
saveManager.Save(state, slot);
int uiCount = state.UnlockedUIFeatures.Count;
int cosCount = state.UnlockedCosmetics.Count;
int advCount = state.UnlockedAdventures.Count;
int wsCount = state.UnlockedWorkstations.Count;
int invCount = state.Inventory.GroupBy(x => x.DefinitionId).Count();
report.AppendLine($"║ Ctrl+{nextSnapshotIdx + 1}: {slot,-14} box #{totalBoxesOpened,-5}" +
$" UI:{uiCount,2} Cos:{cosCount,2} Adv:{advCount,2} WS:{wsCount,2} Inv:{invCount,3} ║");
report.AppendLine($"║ {desc,-56}║");
nextSnapshotIdx++;
}
}
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-38}║");
report.AppendLine($"║ Snapshots generated: {nextSnapshotIdx,-37}║");
report.AppendLine("╚════════════════════════════════════════════════════════════╝");
Console.WriteLine(report.ToString());
Assert.True(nextSnapshotIdx == snapshotDefs.Length,
$"Expected {snapshotDefs.Length} snapshots but only generated {nextSnapshotIdx}. " +
$"Game ran out of boxes at {totalBoxesOpened}.");
}
// ── Playthrough Capture ─────────────────────────────────────────────
/// <summary>
/// Simulates a game playthrough and captures the rendered output at each step.
/// Outputs a detailed report with the action taken and full panel rendering.
/// Run with: dotnet test --filter "PlaythroughCapture" --logger "console;verbosity=detailed"
/// </summary>
[Theory]
[InlineData(42, 15)]
[InlineData(777, 15)]
public void PlaythroughCapture(int seed, int steps)
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
var simulation = new GameSimulation(registry, new Random(seed));
var craftingEngine = new CraftingEngine();
var loc = new LocalizationManager(Locale.FR);
var state = GameState.Create("TestPlayer", Locale.FR);
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
var report = new System.Text.StringBuilder();
report.AppendLine();
report.AppendLine($"╔═══════════════════════════════════════════════════════════════╗");
report.AppendLine($"║ PLAYTHROUGH CAPTURE — seed={seed}, {steps} steps ║");
report.AppendLine($"╚═══════════════════════════════════════════════════════════════╝");
for (int step = 1; step <= steps; step++)
{
// ── Render current game state panels ──
var context = RenderContext.FromGameState(state);
string panelOutput = RenderGameStatePanels(state, context, registry, loc);
report.AppendLine();
report.AppendLine($"┌─── Step {step} ─── Boxes: {state.TotalBoxesOpened} | UI: {state.UnlockedUIFeatures.Count} | Inv: {state.Inventory.Count} ───");
report.AppendLine(panelOutput);
// ── Find a box to open ──
var box = state.Inventory.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
if (box is null)
{
report.AppendLine("│ ACTION: No boxes left — stopping.");
report.AppendLine("└───────────────────────────────────────");
break;
}
var boxDef = registry.GetBox(box.DefinitionId);
string boxName = boxDef is not null ? loc.Get(boxDef.NameKey) : box.DefinitionId;
report.AppendLine($"│ ACTION: Open box \"{boxName}\"");
// ── Open the box ──
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
var events = simulation.ProcessAction(action, state);
// ── Log events ──
foreach (var evt in events)
{
switch (evt)
{
case BoxOpenedEvent boe:
var bd = registry.GetBox(boe.BoxId);
string bn = bd is not null ? loc.Get(bd.NameKey) : boe.BoxId;
if (!boe.IsAutoOpen)
report.AppendLine($"│ EVENT: BoxOpened \"{bn}\"");
break;
case ItemReceivedEvent ire:
var idef = registry.GetItem(ire.Item.DefinitionId);
string iname = idef is not null ? loc.Get(idef.NameKey) : ire.Item.DefinitionId;
string rarity = (idef?.Rarity ?? ItemRarity.Common).ToString();
report.AppendLine($"│ EVENT: Received [{rarity}] \"{iname}\"");
break;
case UIFeatureUnlockedEvent ufe:
context.Unlock(ufe.Feature);
report.AppendLine($"│ EVENT: ★ UI Feature unlocked: {ufe.Feature}");
break;
case AdventureUnlockedEvent aue:
report.AppendLine($"│ EVENT: Adventure unlocked: {aue.Theme}");
break;
case ResourceChangedEvent rce:
report.AppendLine($"│ EVENT: Resource {rce.Type}: {rce.OldValue} → {rce.NewValue}");
break;
case CraftingStartedEvent cse:
report.AppendLine($"│ EVENT: Crafting started: {cse.RecipeId} at {cse.Workstation}");
break;
case InteractionTriggeredEvent ite:
report.AppendLine($"│ EVENT: Interaction: {ite.DescriptionKey}");
break;
case MessageEvent me:
report.AppendLine($"│ EVENT: Message: {me.MessageKey}");
break;
}
}
// ── 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);
var coll = craftingEngine.CollectCompleted(state, registry);
foreach (var ce in coll.OfType<ItemReceivedEvent>())
{
var cd = registry.GetItem(ce.Item.DefinitionId);
string cn = cd is not null ? loc.Get(cd.NameKey) : ce.Item.DefinitionId;
report.AppendLine($"│ CRAFT: Collected \"{cn}\"");
}
var cascade = craftingEngine.AutoCraftCheck(state, registry);
keepCrafting = cascade.OfType<CraftingStartedEvent>().Any();
} while (keepCrafting);
state.TotalBoxesOpened++;
report.AppendLine("└───────────────────────────────────────");
}
// ── Final state rendering ──
report.AppendLine();
report.AppendLine("╔═══════════════════════════════════════════════════════════════╗");
report.AppendLine($"║ FINAL STATE — {state.TotalBoxesOpened} boxes opened ║");
report.AppendLine("╚═══════════════════════════════════════════════════════════════╝");
var finalCtx = RenderContext.FromGameState(state);
report.AppendLine(RenderGameStatePanels(state, finalCtx, registry, loc));
Console.WriteLine(report.ToString());
Assert.True(true);
}
/// <summary>
/// Renders game state panels to a plain string (strips ANSI for readability).
/// </summary>
private static string RenderGameStatePanels(
GameState state, RenderContext ctx,
ContentRegistry registry, LocalizationManager loc)
{
var sb = new System.Text.StringBuilder();
var writer = new StringWriter();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Out = new AnsiConsoleOutput(writer),
Ansi = AnsiSupport.No, // plain text, no ANSI escapes
ColorSystem = ColorSystemSupport.NoColors
});
console.Profile.Width = SpectreRenderer.RefWidth;
// Portrait
if (ctx.HasPortraitPanel)
{
console.Write(PortraitPanel.Render(state.Appearance));
}
// Stats
if (ctx.HasStatsPanel)
{
console.Write(StatsPanel.Render(state, loc));
}
// Resources
if (ctx.HasResourcePanel)
{
console.Write(ResourcePanel.Render(state));
}
// Inventory (compact)
if (ctx.HasInventoryPanel)
{
console.Write(InventoryPanel.Render(state, registry, loc, compact: true));
}
// Crafting
if (ctx.HasCraftingPanel)
{
console.Write(CraftingPanel.Render(state, registry, loc));
}
string output = writer.ToString();
if (!string.IsNullOrWhiteSpace(output))
{
foreach (var line in output.Split('\n'))
sb.AppendLine($"│ {line.TrimEnd()}");
}
else
{
sb.AppendLine("│ (no panels unlocked yet)");
}
return sb.ToString();
}
// ── Inventory Render Capture ────────────────────────────────────────
/// <summary>
/// Captures the interactive inventory rendering at multiple progression stages.
/// Shows the inventory table with selection highlight and detail panels
/// for each item type (consumable, lore, cosmetic, material).
/// Run with: dotnet test --filter "InventoryRenderCapture" --logger "console;verbosity=detailed"
/// </summary>
[Fact]
public void InventoryRenderCapture()
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
var simulation = new GameSimulation(registry, new Random(42));
var craftingEngine = new CraftingEngine();
var loc = new LocalizationManager(Locale.FR);
var state = GameState.Create("InvPlayer", Locale.FR);
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
var report = new System.Text.StringBuilder();
report.AppendLine();
report.AppendLine("╔═══════════════════════════════════════════════════════════════════╗");
report.AppendLine("║ INVENTORY RENDER CAPTURE — seed=42 ║");
report.AppendLine("╚═══════════════════════════════════════════════════════════════════╝");
// Snapshot points for inventory renders
var capturePoints = new[] { 20, 50, 100, 200, 500 };
int nextCapture = 0;
var writer = new StringWriter();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Out = new AnsiConsoleOutput(writer),
Ansi = AnsiSupport.No,
ColorSystem = ColorSystemSupport.NoColors
});
console.Profile.Width = SpectreRenderer.RefWidth;
for (int i = 0; i < 5000 && state.TotalBoxesOpened < 500; 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 };
simulation.ProcessAction(action, state);
// 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);
state.TotalBoxesOpened++;
if (nextCapture < capturePoints.Length &&
state.TotalBoxesOpened >= capturePoints[nextCapture])
{
report.AppendLine();
report.AppendLine($"┌─── Box #{state.TotalBoxesOpened} ── Inventory: {state.Inventory.GroupBy(x => x.DefinitionId).Count()} types ───");
var grouped = InventoryPanel.GetGroupedItems(state, registry);
// Render inventory with first item selected
writer.GetStringBuilder().Clear();
console.Write(InventoryPanel.Render(state, registry, loc, selectedIndex: 0));
foreach (var line in writer.ToString().Split('\n'))
report.AppendLine($"│ {line.TrimEnd()}");
// Render detail panel for first item
if (grouped.Count > 0)
{
writer.GetStringBuilder().Clear();
var detail = InventoryPanel.RenderDetailPanel(grouped[0], registry, loc, state);
if (detail is not null)
{
console.Write(detail);
foreach (var line in writer.ToString().Split('\n'))
report.AppendLine($"│ {line.TrimEnd()}");
}
}
// Show detail panel for specific item types if present
var interestingTypes = new[] { ItemCategory.Box, ItemCategory.Consumable, ItemCategory.LoreFragment, ItemCategory.Cosmetic, ItemCategory.Material };
foreach (var cat in interestingTypes)
{
var sample = grouped.FirstOrDefault(g => g.Category == cat);
if (sample is not null && (grouped.Count == 0 || sample != grouped[0]))
{
int idx = grouped.IndexOf(sample);
report.AppendLine($"│ ── Detail for [{cat}]: {sample.DefId} ──");
writer.GetStringBuilder().Clear();
var detailAlt = InventoryPanel.RenderDetailPanel(sample, registry, loc, state);
if (detailAlt is not null)
{
console.Write(detailAlt);
foreach (var line in writer.ToString().Split('\n'))
report.AppendLine($"│ {line.TrimEnd()}");
}
}
}
report.AppendLine("└───────────────────────────────────────────────────────────────");
nextCapture++;
}
}
Console.WriteLine(report.ToString());
Assert.True(nextCapture > 0, "No captures were taken — game may not have generated enough boxes.");
}
// ── 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)!;
}
}