From 9c4fd0d73a096527fea1fbb6e0b17b2d88394b30 Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Tue, 10 Mar 2026 19:25:02 +0100 Subject: [PATCH] Fix runtime deserialization bugs, add test suite and publish support - Fix LootTable.GuaranteedRolls type (int -> List) to match JSON schema - Fix BoxEngine guaranteed rolls to iterate item IDs directly - Fix BoxEngine resource condition evaluation for "any" targetId - Make ItemDefinition.DescriptionKey optional - Fix font meta item nameKeys to use proper localization keys - Add 43 xUnit content validation tests (deserialization, cross-refs, localization) - Add self-contained single-file publish via publish.ps1 - Update README with distribute and test sections --- .gitignore | 1 + OpenTheBox.slnx | 3 + README.md | 17 + content/data/items.json | 6 +- content/strings/en.json | 4 + content/strings/fr.json | 4 + publish.ps1 | 53 +++ src/OpenTheBox/Core/Boxes/LootTable.cs | 2 +- src/OpenTheBox/Core/Items/ItemDefinition.cs | 2 +- src/OpenTheBox/OpenTheBox.csproj | 4 + src/OpenTheBox/Simulation/BoxEngine.cs | 66 ++- .../OpenTheBox.Tests/OpenTheBox.Tests.csproj | 32 ++ tests/OpenTheBox.Tests/UnitTest1.cs | 444 ++++++++++++++++++ 13 files changed, 599 insertions(+), 39 deletions(-) create mode 100644 publish.ps1 create mode 100644 tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj create mode 100644 tests/OpenTheBox.Tests/UnitTest1.cs diff --git a/.gitignore b/.gitignore index 481723e..31ae740 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ lib/ [Rr]elease/ x64/ x86/ +publish/ ## Saves saves/ diff --git a/OpenTheBox.slnx b/OpenTheBox.slnx index b2df31d..0698c35 100644 --- a/OpenTheBox.slnx +++ b/OpenTheBox.slnx @@ -2,4 +2,7 @@ + + + diff --git a/README.md b/README.md index 9946402..9dfde3e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,23 @@ dotnet build dotnet run --project src/OpenTheBox ``` +## Distribute + +```powershell +.\publish.ps1 # Builds win-x64 by default +.\publish.ps1 -Runtime win-arm64 # Or target another platform +``` + +This produces a self-contained single-file executable in `publish//`. The target machine does **not** need .NET installed. Distribute the entire folder (exe + `content/`). + +## Tests + +```powershell +dotnet test +``` + +43 content validation tests verify all JSON data files deserialize correctly, cross-references are valid, and localization keys exist. + ## Project Structure ``` diff --git a/content/data/items.json b/content/data/items.json index 731e8cb..8a07dc5 100644 --- a/content/data/items.json +++ b/content/data/items.json @@ -25,9 +25,9 @@ {"id": "meta_stat_charisma", "nameKey": "stat.charisma", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "StatVisibility"], "statType": "Charisma"}, {"id": "meta_stat_dexterity", "nameKey": "stat.dexterity", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "StatVisibility"], "statType": "Dexterity"}, {"id": "meta_stat_wisdom", "nameKey": "stat.wisdom", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "StatVisibility"], "statType": "Wisdom"}, - {"id": "meta_font_consolas", "nameKey": "Consolas", "category": "Meta", "rarity": "Uncommon", "tags": ["Meta", "Font"], "fontStyle": "Consolas"}, - {"id": "meta_font_firetruc", "nameKey": "Firetruc", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "Font"], "fontStyle": "Firetruc"}, - {"id": "meta_font_jetbrains", "nameKey": "JetBrains Mono", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "Font"], "fontStyle": "Jetbrains"}, + {"id": "meta_font_consolas", "nameKey": "item.meta_font_consolas", "category": "Meta", "rarity": "Uncommon", "tags": ["Meta", "Font"], "fontStyle": "Consolas"}, + {"id": "meta_font_firetruc", "nameKey": "item.meta_font_firetruc", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "Font"], "fontStyle": "Firetruc"}, + {"id": "meta_font_jetbrains", "nameKey": "item.meta_font_jetbrains", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "Font"], "fontStyle": "Jetbrains"}, {"id": "cosmetic_hair_short", "nameKey": "cosmetic.hair.short", "category": "Cosmetic", "rarity": "Common", "tags": ["Cosmetic"], "cosmeticSlot": "Hair", "cosmeticValue": "Short"}, {"id": "cosmetic_hair_long", "nameKey": "cosmetic.hair.long", "category": "Cosmetic", "rarity": "Common", "tags": ["Cosmetic"], "cosmeticSlot": "Hair", "cosmeticValue": "Long"}, diff --git a/content/strings/en.json b/content/strings/en.json index ed67642..ea243b9 100644 --- a/content/strings/en.json +++ b/content/strings/en.json @@ -124,6 +124,10 @@ "stat.dexterity": "Dexterity", "stat.wisdom": "Wisdom", + "item.meta_font_consolas": "Font: Consolas", + "item.meta_font_firetruc": "Font: Firetruc", + "item.meta_font_jetbrains": "Font: JetBrains Mono", + "cosmetic.hair.none": "Bald", "cosmetic.hair.short": "Short Hair", "cosmetic.hair.long": "Long Hair", diff --git a/content/strings/fr.json b/content/strings/fr.json index e9161d5..3eeca14 100644 --- a/content/strings/fr.json +++ b/content/strings/fr.json @@ -124,6 +124,10 @@ "stat.dexterity": "Dexterite", "stat.wisdom": "Sagesse", + "item.meta_font_consolas": "Police : Consolas", + "item.meta_font_firetruc": "Police : Firetruc", + "item.meta_font_jetbrains": "Police : JetBrains Mono", + "cosmetic.hair.none": "Chauve", "cosmetic.hair.short": "Cheveux courts", "cosmetic.hair.long": "Cheveux longs", diff --git a/publish.ps1 b/publish.ps1 new file mode 100644 index 0000000..f7c3719 --- /dev/null +++ b/publish.ps1 @@ -0,0 +1,53 @@ +<# +.SYNOPSIS + Builds a self-contained single-file executable for distribution. +.DESCRIPTION + Publishes Open The Box as a standalone .exe that includes the .NET runtime. + No .NET SDK or runtime installation needed on target machines. +.PARAMETER Runtime + Target runtime identifier. Default: win-x64 + Examples: win-x64, win-arm64, linux-x64, osx-x64 +#> +param( + [string]$Runtime = "win-x64" +) + +$ErrorActionPreference = "Stop" + +Write-Host "=== Open The Box - Publish ===" -ForegroundColor Cyan +Write-Host "Runtime: $Runtime" +Write-Host "" + +$OutputDir = Join-Path $PSScriptRoot "publish" $Runtime + +# Clean previous publish +if (Test-Path $OutputDir) { + Remove-Item $OutputDir -Recurse -Force +} + +Write-Host "Publishing self-contained single-file executable..." -ForegroundColor Yellow + +dotnet publish src/OpenTheBox/OpenTheBox.csproj ` + -c Release ` + -r $Runtime ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -o $OutputDir + +if ($LASTEXITCODE -ne 0) { + Write-Host "Publish failed!" -ForegroundColor Red + exit 1 +} + +# Show output +Write-Host "" +Write-Host "Published to: $OutputDir" -ForegroundColor Green +$exe = Get-ChildItem $OutputDir -Filter "OpenTheBox*" | Where-Object { $_.Extension -in ".exe", "" } | Select-Object -First 1 +if ($exe) { + $sizeMB = [math]::Round($exe.Length / 1MB, 1) + Write-Host "Executable: $($exe.Name) ($sizeMB MB)" -ForegroundColor Green +} +Write-Host "" +Write-Host "To distribute, copy the entire '$OutputDir' folder." -ForegroundColor Cyan +Write-Host "The content/ folder must remain alongside the executable." -ForegroundColor Cyan diff --git a/src/OpenTheBox/Core/Boxes/LootTable.cs b/src/OpenTheBox/Core/Boxes/LootTable.cs index a4ba62c..8a07171 100644 --- a/src/OpenTheBox/Core/Boxes/LootTable.cs +++ b/src/OpenTheBox/Core/Boxes/LootTable.cs @@ -5,6 +5,6 @@ namespace OpenTheBox.Core.Boxes; /// public sealed record LootTable( List Entries, - int GuaranteedRolls, + List GuaranteedRolls, int RollCount ); diff --git a/src/OpenTheBox/Core/Items/ItemDefinition.cs b/src/OpenTheBox/Core/Items/ItemDefinition.cs index f190028..8b99953 100644 --- a/src/OpenTheBox/Core/Items/ItemDefinition.cs +++ b/src/OpenTheBox/Core/Items/ItemDefinition.cs @@ -8,10 +8,10 @@ namespace OpenTheBox.Core.Items; public sealed record ItemDefinition( string Id, string NameKey, - string DescriptionKey, ItemCategory Category, ItemRarity Rarity, HashSet Tags, + string? DescriptionKey = null, UIFeature? MetaUnlock = null, CosmeticSlot? CosmeticSlot = null, string? CosmeticValue = null, diff --git a/src/OpenTheBox/OpenTheBox.csproj b/src/OpenTheBox/OpenTheBox.csproj index df988d4..c7043b9 100644 --- a/src/OpenTheBox/OpenTheBox.csproj +++ b/src/OpenTheBox/OpenTheBox.csproj @@ -6,6 +6,10 @@ enable enable OpenTheBox + OpenTheBox + true + true + true diff --git a/src/OpenTheBox/Simulation/BoxEngine.cs b/src/OpenTheBox/Simulation/BoxEngine.cs index 35e6499..b7dc271 100644 --- a/src/OpenTheBox/Simulation/BoxEngine.cs +++ b/src/OpenTheBox/Simulation/BoxEngine.cs @@ -24,25 +24,16 @@ public class BoxEngine(ContentRegistry registry) if (boxDef is null) return events; - var eligibleEntries = FilterEligibleEntries(boxDef.LootTable, state); - if (eligibleEntries.Count == 0) - return events; - var droppedItemDefIds = new List(); - // Handle guaranteed rolls: take the top entries by weight up to GuaranteedRolls count - var guaranteedCount = Math.Min(boxDef.LootTable.GuaranteedRolls, eligibleEntries.Count); - var guaranteedEntries = eligibleEntries - .OrderByDescending(e => e.Weight) - .Take(guaranteedCount) - .ToList(); - - foreach (var entry in guaranteedEntries) + // Handle guaranteed rolls: specific item IDs that always drop + foreach (var guaranteedId in boxDef.LootTable.GuaranteedRolls) { - droppedItemDefIds.Add(entry.ItemDefinitionId); + droppedItemDefIds.Add(guaranteedId); } // Handle weighted random rolls + var eligibleEntries = FilterEligibleEntries(boxDef.LootTable, state); if (boxDef.LootTable.RollCount > 0 && eligibleEntries.Count > 0) { var weightedEntries = eligibleEntries @@ -61,24 +52,17 @@ public class BoxEngine(ContentRegistry registry) // Create item instances for each dropped item foreach (var itemDefId in droppedItemDefIds) { - var itemDef = registry.GetItem(itemDefId); - if (itemDef is null) - continue; - var instance = ItemInstance.Create(itemDefId); state.AddItem(instance); events.Add(new ItemReceivedEvent(instance)); - // Recursively open auto-open boxes - if (itemDef.Category == ItemCategory.Box) + // Check if this is a box and handle auto-open + var nestedBoxDef = registry.GetBox(itemDefId); + if (nestedBoxDef is not null && nestedBoxDef.IsAutoOpen) { - var nestedBoxDef = registry.GetBox(itemDefId); - if (nestedBoxDef is not null && nestedBoxDef.IsAutoOpen) - { - state.RemoveItem(instance.Id); - events.Add(new ItemConsumedEvent(instance.Id)); - events.AddRange(Open(itemDefId, state, rng)); - } + state.RemoveItem(instance.Id); + events.Add(new ItemConsumedEvent(instance.Id)); + events.AddRange(Open(itemDefId, state, rng)); } } @@ -110,14 +94,10 @@ public class BoxEngine(ContentRegistry registry) { LootConditionType.HasItem => condition.TargetId is not null && state.HasItem(condition.TargetId), LootConditionType.HasNotItem => condition.TargetId is not null && !state.HasItem(condition.TargetId), - LootConditionType.ResourceAbove => condition.TargetId is not null - && condition.Value.HasValue - && Enum.TryParse(condition.TargetId, out var resAbove) - && state.GetResource(resAbove) > condition.Value.Value, - LootConditionType.ResourceBelow => condition.TargetId is not null - && condition.Value.HasValue - && Enum.TryParse(condition.TargetId, out var resBelow) - && state.GetResource(resBelow) < condition.Value.Value, + LootConditionType.ResourceAbove => condition.Value.HasValue + && EvaluateResourceCondition(condition.TargetId, condition.Value.Value, state, above: true), + LootConditionType.ResourceBelow => condition.Value.HasValue + && EvaluateResourceCondition(condition.TargetId, condition.Value.Value, state, above: false), LootConditionType.HasUIFeature => condition.TargetId is not null && Enum.TryParse(condition.TargetId, out var feature) && state.HasUIFeature(feature), @@ -135,6 +115,24 @@ public class BoxEngine(ContentRegistry registry) }; } + /// + /// Evaluates a resource condition, supporting "any" to match any visible resource. + /// + private static bool EvaluateResourceCondition(string? targetId, float value, GameState state, bool above) + { + if (targetId is null) return false; + + if (targetId.Equals("any", StringComparison.OrdinalIgnoreCase)) + { + return state.VisibleResources.Any(r => + above ? state.GetResource(r) > value : state.GetResource(r) < value); + } + + if (!Enum.TryParse(targetId, out var resType)) return false; + var actual = state.GetResource(resType); + return above ? actual > value : actual < value; + } + /// /// Performs a comparison operation between an actual value and a target value. /// diff --git a/tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj b/tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj new file mode 100644 index 0000000..69a7bc7 --- /dev/null +++ b/tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + PreserveNewest + content\%(RecursiveDir)%(Filename)%(Extension) + + + + \ No newline at end of file diff --git a/tests/OpenTheBox.Tests/UnitTest1.cs b/tests/OpenTheBox.Tests/UnitTest1.cs new file mode 100644 index 0000000..e7f3361 --- /dev/null +++ b/tests/OpenTheBox.Tests/UnitTest1.cs @@ -0,0 +1,444 @@ +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; + +/// +/// Validates that all JSON content files deserialize correctly and are internally consistent. +/// These tests catch data issues (typos, missing references, schema mismatches) before runtime. +/// +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>(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>(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(); + 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(); + 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(); + 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(); + 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>(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(); + 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(); + foreach (var recipe in doc.RootElement.EnumerateArray()) + { + var recipeId = recipe.GetProperty("id").GetString()!; + var workstation = recipe.GetProperty("workstation").GetString()!; + if (!Enum.TryParse(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>(json); + + Assert.NotNull(dict); + Assert.NotEmpty(dict); + } + + [Fact] + public void FrStrings_IsValidJson() + { + var json = File.ReadAllText(FrStringsPath); + var dict = JsonSerializer.Deserialize>(json); + + Assert.NotNull(dict); + Assert.NotEmpty(dict); + } + + [Fact] + public void FrStrings_HasAllKeysFromEn() + { + var enJson = File.ReadAllText(EnStringsPath); + var en = JsonSerializer.Deserialize>(enJson)!; + var frJson = File.ReadAllText(FrStringsPath); + var fr = JsonSerializer.Deserialize>(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); + } + + // ── 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}"); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static List LoadItems() + { + var json = File.ReadAllText(ItemsPath); + return JsonSerializer.Deserialize>(json, JsonOptions)!; + } + + private static List LoadBoxes() + { + var json = File.ReadAllText(BoxesPath); + return JsonSerializer.Deserialize>(json, JsonOptions)!; + } + + private static List LoadInteractions() + { + var json = File.ReadAllText(InteractionsPath); + return JsonSerializer.Deserialize>(json, JsonOptions)!; + } + + private static Dictionary LoadEnStrings() + { + var json = File.ReadAllText(EnStringsPath); + return JsonSerializer.Deserialize>(json)!; + } +}