Fix runtime deserialization bugs, add test suite and publish support

- Fix LootTable.GuaranteedRolls type (int -> List<string>) 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
This commit is contained in:
Samuel Bouchet 2026-03-10 19:25:02 +01:00
parent 05289da2e2
commit 9c4fd0d73a
13 changed files with 599 additions and 39 deletions

1
.gitignore vendored
View file

@ -25,6 +25,7 @@ lib/
[Rr]elease/
x64/
x86/
publish/
## Saves
saves/

View file

@ -2,4 +2,7 @@
<Folder Name="/src/">
<Project Path="src/OpenTheBox/OpenTheBox.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj" />
</Folder>
</Solution>

View file

@ -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/<runtime>/`. 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
```

View file

@ -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"},

View file

@ -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",

View file

@ -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",

53
publish.ps1 Normal file
View file

@ -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

View file

@ -5,6 +5,6 @@ namespace OpenTheBox.Core.Boxes;
/// </summary>
public sealed record LootTable(
List<LootEntry> Entries,
int GuaranteedRolls,
List<string> GuaranteedRolls,
int RollCount
);

View file

@ -8,10 +8,10 @@ namespace OpenTheBox.Core.Items;
public sealed record ItemDefinition(
string Id,
string NameKey,
string DescriptionKey,
ItemCategory Category,
ItemRarity Rarity,
HashSet<string> Tags,
string? DescriptionKey = null,
UIFeature? MetaUnlock = null,
CosmeticSlot? CosmeticSlot = null,
string? CosmeticValue = null,

View file

@ -6,6 +6,10 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>OpenTheBox</RootNamespace>
<AssemblyName>OpenTheBox</AssemblyName>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
<ItemGroup>

View file

@ -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<string>();
// 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<ResourceType>(condition.TargetId, out var resAbove)
&& state.GetResource(resAbove) > condition.Value.Value,
LootConditionType.ResourceBelow => condition.TargetId is not null
&& condition.Value.HasValue
&& Enum.TryParse<ResourceType>(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<UIFeature>(condition.TargetId, out var feature)
&& state.HasUIFeature(feature),
@ -135,6 +115,24 @@ public class BoxEngine(ContentRegistry registry)
};
}
/// <summary>
/// Evaluates a resource condition, supporting "any" to match any visible resource.
/// </summary>
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<ResourceType>(targetId, out var resType)) return false;
var actual = state.GetResource(resType);
return above ? actual > value : actual < value;
}
/// <summary>
/// Performs a comparison operation between an actual value and a target value.
/// </summary>

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OpenTheBox\OpenTheBox.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\content\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>content\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// Validates that all JSON content files deserialize correctly and are internally consistent.
/// These tests catch data issues (typos, missing references, schema mismatches) before runtime.
/// </summary>
public class ContentValidationTests
{
private static readonly string ContentRoot = "content";
private static readonly string ItemsPath = Path.Combine(ContentRoot, "data", "items.json");
private static readonly string BoxesPath = Path.Combine(ContentRoot, "data", "boxes.json");
private static readonly string InteractionsPath = Path.Combine(ContentRoot, "data", "interactions.json");
private static readonly string RecipesPath = Path.Combine(ContentRoot, "data", "recipes.json");
private static readonly string EnStringsPath = Path.Combine(ContentRoot, "strings", "en.json");
private static readonly string FrStringsPath = Path.Combine(ContentRoot, "strings", "fr.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
// ── Items ────────────────────────────────────────────────────────────
[Fact]
public void ItemsJson_Deserializes()
{
var json = File.ReadAllText(ItemsPath);
var items = JsonSerializer.Deserialize<List<ItemDefinition>>(json, JsonOptions);
Assert.NotNull(items);
Assert.NotEmpty(items);
}
[Fact]
public void ItemsJson_AllIdsAreUnique()
{
var items = LoadItems();
var duplicates = items.GroupBy(i => i.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
Assert.Empty(duplicates);
}
[Fact]
public void ItemsJson_AllHaveNameKeys()
{
var items = LoadItems();
var missing = items.Where(i => string.IsNullOrWhiteSpace(i.NameKey)).Select(i => i.Id).ToList();
Assert.Empty(missing);
}
[Fact]
public void ItemsJson_AllHaveValidCategory()
{
var items = LoadItems();
// If deserialization succeeded with JsonStringEnumConverter, all categories are valid
Assert.All(items, item => Assert.True(Enum.IsDefined(item.Category),
$"Item '{item.Id}' has invalid category"));
}
[Fact]
public void ItemsJson_AllHaveValidRarity()
{
var items = LoadItems();
Assert.All(items, item => Assert.True(Enum.IsDefined(item.Rarity),
$"Item '{item.Id}' has invalid rarity"));
}
// ── Boxes ────────────────────────────────────────────────────────────
[Fact]
public void BoxesJson_Deserializes()
{
var json = File.ReadAllText(BoxesPath);
var boxes = JsonSerializer.Deserialize<List<BoxDefinition>>(json, JsonOptions);
Assert.NotNull(boxes);
Assert.NotEmpty(boxes);
}
[Fact]
public void BoxesJson_AllIdsAreUnique()
{
var boxes = LoadBoxes();
var duplicates = boxes.GroupBy(b => b.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
Assert.Empty(duplicates);
}
[Fact]
public void BoxesJson_GuaranteedRollsReferenceValidItems()
{
var items = LoadItems().Select(i => i.Id).ToHashSet();
var boxes = LoadBoxes();
var boxIds = boxes.Select(b => b.Id).ToHashSet();
var invalid = new List<string>();
foreach (var box in boxes)
{
foreach (var guaranteedId in box.LootTable.GuaranteedRolls)
{
if (!items.Contains(guaranteedId) && !boxIds.Contains(guaranteedId))
invalid.Add($"{box.Id} -> {guaranteedId}");
}
}
Assert.Empty(invalid);
}
[Fact]
public void BoxesJson_LootEntryItemsExist()
{
var items = LoadItems().Select(i => i.Id).ToHashSet();
var boxes = LoadBoxes();
var boxIds = boxes.Select(b => b.Id).ToHashSet();
var invalid = new List<string>();
foreach (var box in boxes)
{
foreach (var entry in box.LootTable.Entries)
{
if (!items.Contains(entry.ItemDefinitionId) && !boxIds.Contains(entry.ItemDefinitionId))
invalid.Add($"{box.Id} -> {entry.ItemDefinitionId}");
}
}
Assert.Empty(invalid);
}
[Fact]
public void BoxesJson_AllEntriesHavePositiveWeight()
{
var boxes = LoadBoxes();
var invalid = new List<string>();
foreach (var box in boxes)
{
foreach (var entry in box.LootTable.Entries)
{
if (entry.Weight <= 0)
invalid.Add($"{box.Id} -> {entry.ItemDefinitionId} (weight={entry.Weight})");
}
}
Assert.Empty(invalid);
}
[Fact]
public void BoxesJson_AllHaveEitherGuaranteedOrRollEntries()
{
var boxes = LoadBoxes();
var empty = boxes
.Where(b => b.LootTable.GuaranteedRolls.Count == 0
&& b.LootTable.Entries.Count == 0)
.Select(b => b.Id)
.ToList();
Assert.Empty(empty);
}
[Fact]
public void BoxesJson_LootConditionsHaveValidTypes()
{
var boxes = LoadBoxes();
// If deserialization with JsonStringEnumConverter worked, all condition types are valid.
// But let's also verify targetId makes sense for specific conditions.
var issues = new List<string>();
foreach (var box in boxes)
{
foreach (var entry in box.LootTable.Entries)
{
if (entry.Condition is not null)
{
Assert.True(Enum.IsDefined(entry.Condition.Type),
$"Box '{box.Id}' entry '{entry.ItemDefinitionId}' has invalid condition type");
if (entry.Condition.Type == LootConditionType.BoxesOpenedAbove
&& !entry.Condition.Value.HasValue)
{
issues.Add($"{box.Id}/{entry.ItemDefinitionId}: BoxesOpenedAbove needs a value");
}
}
}
}
Assert.Empty(issues);
}
// ── Interactions ─────────────────────────────────────────────────────
[Fact]
public void InteractionsJson_Deserializes()
{
var json = File.ReadAllText(InteractionsPath);
var rules = JsonSerializer.Deserialize<List<InteractionRule>>(json, JsonOptions);
Assert.NotNull(rules);
Assert.NotEmpty(rules);
}
[Fact]
public void InteractionsJson_AllIdsAreUnique()
{
var rules = LoadInteractions();
var duplicates = rules.GroupBy(r => r.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
Assert.Empty(duplicates);
}
// ── Recipes ──────────────────────────────────────────────────────────
[Fact]
public void RecipesJson_Deserializes()
{
var json = File.ReadAllText(RecipesPath);
var doc = JsonDocument.Parse(json);
Assert.NotNull(doc);
Assert.True(doc.RootElement.GetArrayLength() > 0, "recipes.json is empty");
}
[Fact]
public void RecipesJson_AllIngredientsExist()
{
var items = LoadItems().Select(i => i.Id).ToHashSet();
var boxes = LoadBoxes().Select(b => b.Id).ToHashSet();
var json = File.ReadAllText(RecipesPath);
var doc = JsonDocument.Parse(json);
var invalid = new List<string>();
foreach (var recipe in doc.RootElement.EnumerateArray())
{
var recipeId = recipe.GetProperty("id").GetString()!;
foreach (var ingredient in recipe.GetProperty("ingredients").EnumerateArray())
{
var itemId = ingredient.GetProperty("itemDefinitionId").GetString()!;
if (!items.Contains(itemId) && !boxes.Contains(itemId))
invalid.Add($"{recipeId} -> ingredient '{itemId}'");
}
var resultId = recipe.GetProperty("result").GetProperty("itemDefinitionId").GetString()!;
if (!items.Contains(resultId) && !boxes.Contains(resultId))
invalid.Add($"{recipeId} -> result '{resultId}'");
}
Assert.Empty(invalid);
}
[Fact]
public void RecipesJson_AllWorkstationsAreValid()
{
var json = File.ReadAllText(RecipesPath);
var doc = JsonDocument.Parse(json);
var invalid = new List<string>();
foreach (var recipe in doc.RootElement.EnumerateArray())
{
var recipeId = recipe.GetProperty("id").GetString()!;
var workstation = recipe.GetProperty("workstation").GetString()!;
if (!Enum.TryParse<WorkstationType>(workstation, out _))
invalid.Add($"{recipeId} -> workstation '{workstation}'");
}
Assert.Empty(invalid);
}
// ── Localization ─────────────────────────────────────────────────────
[Fact]
public void EnStrings_IsValidJson()
{
var json = File.ReadAllText(EnStringsPath);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
Assert.NotNull(dict);
Assert.NotEmpty(dict);
}
[Fact]
public void FrStrings_IsValidJson()
{
var json = File.ReadAllText(FrStringsPath);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
Assert.NotNull(dict);
Assert.NotEmpty(dict);
}
[Fact]
public void FrStrings_HasAllKeysFromEn()
{
var enJson = File.ReadAllText(EnStringsPath);
var en = JsonSerializer.Deserialize<Dictionary<string, string>>(enJson)!;
var frJson = File.ReadAllText(FrStringsPath);
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
var missing = en.Keys.Where(k => !fr.ContainsKey(k)).ToList();
Assert.Empty(missing);
}
[Fact]
public void ItemNameKeys_ExistInLocalization()
{
var items = LoadItems();
var en = LoadEnStrings();
var missing = items
.Where(i => !en.ContainsKey(i.NameKey))
.Select(i => $"{i.Id} -> nameKey '{i.NameKey}'")
.ToList();
Assert.Empty(missing);
}
[Fact]
public void BoxNameKeys_ExistInLocalization()
{
var boxes = LoadBoxes();
var en = LoadEnStrings();
var missing = boxes
.Where(b => !en.ContainsKey(b.NameKey))
.Select(b => $"{b.Id} -> nameKey '{b.NameKey}'")
.ToList();
Assert.Empty(missing);
}
// ── ContentRegistry integration ──────────────────────────────────────
[Fact]
public void ContentRegistry_LoadsSuccessfully()
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
Assert.True(registry.Items.Count > 0, "No items loaded");
Assert.True(registry.Boxes.Count > 0, "No boxes loaded");
Assert.True(registry.InteractionRules.Count > 0, "No interaction rules loaded");
}
[Fact]
public void ContentRegistry_StarterBoxExists()
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
var starter = registry.GetBox("box_starter");
Assert.NotNull(starter);
Assert.NotEmpty(starter.LootTable.GuaranteedRolls);
}
// ── Simulation smoke test ────────────────────────────────────────────
[Fact]
public void Simulation_OpenStarterBox_ProducesEvents()
{
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
var simulation = new GameSimulation(registry, new Random(42));
var state = GameState.Create("TestPlayer", Locale.EN);
// Give the player a starter box
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" };
var events = simulation.ProcessAction(action, state);
Assert.NotEmpty(events);
Assert.Contains(events, e => e is BoxOpenedEvent);
Assert.Contains(events, e => e is ItemReceivedEvent);
}
// ── 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<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)!;
}
}