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:
parent
05289da2e2
commit
9c4fd0d73a
13 changed files with 599 additions and 39 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,6 +25,7 @@ lib/
|
||||||
[Rr]elease/
|
[Rr]elease/
|
||||||
x64/
|
x64/
|
||||||
x86/
|
x86/
|
||||||
|
publish/
|
||||||
|
|
||||||
## Saves
|
## Saves
|
||||||
saves/
|
saves/
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,7 @@
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/OpenTheBox/OpenTheBox.csproj" />
|
<Project Path="src/OpenTheBox/OpenTheBox.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -70,6 +70,23 @@ dotnet build
|
||||||
dotnet run --project src/OpenTheBox
|
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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,9 @@
|
||||||
{"id": "meta_stat_charisma", "nameKey": "stat.charisma", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "StatVisibility"], "statType": "Charisma"},
|
{"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_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_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_consolas", "nameKey": "item.meta_font_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_firetruc", "nameKey": "item.meta_font_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_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_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"},
|
{"id": "cosmetic_hair_long", "nameKey": "cosmetic.hair.long", "category": "Cosmetic", "rarity": "Common", "tags": ["Cosmetic"], "cosmeticSlot": "Hair", "cosmeticValue": "Long"},
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,10 @@
|
||||||
"stat.dexterity": "Dexterity",
|
"stat.dexterity": "Dexterity",
|
||||||
"stat.wisdom": "Wisdom",
|
"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.none": "Bald",
|
||||||
"cosmetic.hair.short": "Short Hair",
|
"cosmetic.hair.short": "Short Hair",
|
||||||
"cosmetic.hair.long": "Long Hair",
|
"cosmetic.hair.long": "Long Hair",
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,10 @@
|
||||||
"stat.dexterity": "Dexterite",
|
"stat.dexterity": "Dexterite",
|
||||||
"stat.wisdom": "Sagesse",
|
"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.none": "Chauve",
|
||||||
"cosmetic.hair.short": "Cheveux courts",
|
"cosmetic.hair.short": "Cheveux courts",
|
||||||
"cosmetic.hair.long": "Cheveux longs",
|
"cosmetic.hair.long": "Cheveux longs",
|
||||||
|
|
|
||||||
53
publish.ps1
Normal file
53
publish.ps1
Normal 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
|
||||||
|
|
@ -5,6 +5,6 @@ namespace OpenTheBox.Core.Boxes;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record LootTable(
|
public sealed record LootTable(
|
||||||
List<LootEntry> Entries,
|
List<LootEntry> Entries,
|
||||||
int GuaranteedRolls,
|
List<string> GuaranteedRolls,
|
||||||
int RollCount
|
int RollCount
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ namespace OpenTheBox.Core.Items;
|
||||||
public sealed record ItemDefinition(
|
public sealed record ItemDefinition(
|
||||||
string Id,
|
string Id,
|
||||||
string NameKey,
|
string NameKey,
|
||||||
string DescriptionKey,
|
|
||||||
ItemCategory Category,
|
ItemCategory Category,
|
||||||
ItemRarity Rarity,
|
ItemRarity Rarity,
|
||||||
HashSet<string> Tags,
|
HashSet<string> Tags,
|
||||||
|
string? DescriptionKey = null,
|
||||||
UIFeature? MetaUnlock = null,
|
UIFeature? MetaUnlock = null,
|
||||||
CosmeticSlot? CosmeticSlot = null,
|
CosmeticSlot? CosmeticSlot = null,
|
||||||
string? CosmeticValue = null,
|
string? CosmeticValue = null,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>OpenTheBox</RootNamespace>
|
<RootNamespace>OpenTheBox</RootNamespace>
|
||||||
|
<AssemblyName>OpenTheBox</AssemblyName>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -24,25 +24,16 @@ public class BoxEngine(ContentRegistry registry)
|
||||||
if (boxDef is null)
|
if (boxDef is null)
|
||||||
return events;
|
return events;
|
||||||
|
|
||||||
var eligibleEntries = FilterEligibleEntries(boxDef.LootTable, state);
|
|
||||||
if (eligibleEntries.Count == 0)
|
|
||||||
return events;
|
|
||||||
|
|
||||||
var droppedItemDefIds = new List<string>();
|
var droppedItemDefIds = new List<string>();
|
||||||
|
|
||||||
// Handle guaranteed rolls: take the top entries by weight up to GuaranteedRolls count
|
// Handle guaranteed rolls: specific item IDs that always drop
|
||||||
var guaranteedCount = Math.Min(boxDef.LootTable.GuaranteedRolls, eligibleEntries.Count);
|
foreach (var guaranteedId in boxDef.LootTable.GuaranteedRolls)
|
||||||
var guaranteedEntries = eligibleEntries
|
|
||||||
.OrderByDescending(e => e.Weight)
|
|
||||||
.Take(guaranteedCount)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var entry in guaranteedEntries)
|
|
||||||
{
|
{
|
||||||
droppedItemDefIds.Add(entry.ItemDefinitionId);
|
droppedItemDefIds.Add(guaranteedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle weighted random rolls
|
// Handle weighted random rolls
|
||||||
|
var eligibleEntries = FilterEligibleEntries(boxDef.LootTable, state);
|
||||||
if (boxDef.LootTable.RollCount > 0 && eligibleEntries.Count > 0)
|
if (boxDef.LootTable.RollCount > 0 && eligibleEntries.Count > 0)
|
||||||
{
|
{
|
||||||
var weightedEntries = eligibleEntries
|
var weightedEntries = eligibleEntries
|
||||||
|
|
@ -61,17 +52,11 @@ public class BoxEngine(ContentRegistry registry)
|
||||||
// Create item instances for each dropped item
|
// Create item instances for each dropped item
|
||||||
foreach (var itemDefId in droppedItemDefIds)
|
foreach (var itemDefId in droppedItemDefIds)
|
||||||
{
|
{
|
||||||
var itemDef = registry.GetItem(itemDefId);
|
|
||||||
if (itemDef is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var instance = ItemInstance.Create(itemDefId);
|
var instance = ItemInstance.Create(itemDefId);
|
||||||
state.AddItem(instance);
|
state.AddItem(instance);
|
||||||
events.Add(new ItemReceivedEvent(instance));
|
events.Add(new ItemReceivedEvent(instance));
|
||||||
|
|
||||||
// Recursively open auto-open boxes
|
// Check if this is a box and handle auto-open
|
||||||
if (itemDef.Category == ItemCategory.Box)
|
|
||||||
{
|
|
||||||
var nestedBoxDef = registry.GetBox(itemDefId);
|
var nestedBoxDef = registry.GetBox(itemDefId);
|
||||||
if (nestedBoxDef is not null && nestedBoxDef.IsAutoOpen)
|
if (nestedBoxDef is not null && nestedBoxDef.IsAutoOpen)
|
||||||
{
|
{
|
||||||
|
|
@ -80,7 +65,6 @@ public class BoxEngine(ContentRegistry registry)
|
||||||
events.AddRange(Open(itemDefId, state, rng));
|
events.AddRange(Open(itemDefId, state, rng));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
@ -110,14 +94,10 @@ public class BoxEngine(ContentRegistry registry)
|
||||||
{
|
{
|
||||||
LootConditionType.HasItem => condition.TargetId is not null && state.HasItem(condition.TargetId),
|
LootConditionType.HasItem => condition.TargetId is not null && state.HasItem(condition.TargetId),
|
||||||
LootConditionType.HasNotItem => 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
|
LootConditionType.ResourceAbove => condition.Value.HasValue
|
||||||
&& condition.Value.HasValue
|
&& EvaluateResourceCondition(condition.TargetId, condition.Value.Value, state, above: true),
|
||||||
&& Enum.TryParse<ResourceType>(condition.TargetId, out var resAbove)
|
LootConditionType.ResourceBelow => condition.Value.HasValue
|
||||||
&& state.GetResource(resAbove) > condition.Value.Value,
|
&& EvaluateResourceCondition(condition.TargetId, condition.Value.Value, state, above: false),
|
||||||
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.HasUIFeature => condition.TargetId is not null
|
LootConditionType.HasUIFeature => condition.TargetId is not null
|
||||||
&& Enum.TryParse<UIFeature>(condition.TargetId, out var feature)
|
&& Enum.TryParse<UIFeature>(condition.TargetId, out var feature)
|
||||||
&& state.HasUIFeature(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>
|
/// <summary>
|
||||||
/// Performs a comparison operation between an actual value and a target value.
|
/// Performs a comparison operation between an actual value and a target value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
32
tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj
Normal file
32
tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj
Normal 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>
|
||||||
444
tests/OpenTheBox.Tests/UnitTest1.cs
Normal file
444
tests/OpenTheBox.Tests/UnitTest1.cs
Normal 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)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue