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)!;
+ }
+}