- Fix Loreline parsing: escape quotes in dialogue, remove [if] bracket syntax, remove # in text conflicting with tags - Add French accents to all 9 .fr.lor translation files (hundreds of fixes) - Fix cosmetic equip display: use item nameKey lookup instead of constructing key from cosmeticValue (fixes StardustLegendary MISSING) - Deduplicate cosmetics in appearance menu - Localize all hardcoded strings (welcome, inventory, adventure, cosmetic) - Add new tests: Loreline parsing (19), cosmetic slot keys, slot+value uniqueness (302 total, 0 failures)
1236 lines
50 KiB
C#
1236 lines
50 KiB
C#
using System.Text.Json;
|
|
using OpenTheBox.Core;
|
|
using OpenTheBox.Core.Boxes;
|
|
using OpenTheBox.Core.Crafting;
|
|
using OpenTheBox.Core.Enums;
|
|
using OpenTheBox.Core.Interactions;
|
|
using OpenTheBox.Core.Items;
|
|
using OpenTheBox.Data;
|
|
using OpenTheBox.Simulation;
|
|
using OpenTheBox.Simulation.Actions;
|
|
using OpenTheBox.Simulation.Events;
|
|
using Loreline;
|
|
|
|
namespace OpenTheBox.Tests;
|
|
|
|
/// <summary>
|
|
/// Validates that all JSON content files deserialize correctly and are internally consistent.
|
|
/// These tests catch data issues (typos, missing references, schema mismatches) before runtime.
|
|
/// </summary>
|
|
public class ContentValidationTests
|
|
{
|
|
private static readonly string ContentRoot = "content";
|
|
private static readonly string ItemsPath = Path.Combine(ContentRoot, "data", "items.json");
|
|
private static readonly string BoxesPath = Path.Combine(ContentRoot, "data", "boxes.json");
|
|
private static readonly string InteractionsPath = Path.Combine(ContentRoot, "data", "interactions.json");
|
|
private static readonly string RecipesPath = Path.Combine(ContentRoot, "data", "recipes.json");
|
|
private static readonly string EnStringsPath = Path.Combine(ContentRoot, "strings", "en.json");
|
|
private static readonly string FrStringsPath = Path.Combine(ContentRoot, "strings", "fr.json");
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
|
};
|
|
|
|
// ── Items ────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ItemsJson_Deserializes()
|
|
{
|
|
var json = File.ReadAllText(ItemsPath);
|
|
var items = JsonSerializer.Deserialize<List<ItemDefinition>>(json, JsonOptions);
|
|
|
|
Assert.NotNull(items);
|
|
Assert.NotEmpty(items);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemsJson_AllIdsAreUnique()
|
|
{
|
|
var items = LoadItems();
|
|
var duplicates = items.GroupBy(i => i.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
|
|
|
|
Assert.Empty(duplicates);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemsJson_AllHaveNameKeys()
|
|
{
|
|
var items = LoadItems();
|
|
var missing = items.Where(i => string.IsNullOrWhiteSpace(i.NameKey)).Select(i => i.Id).ToList();
|
|
|
|
Assert.Empty(missing);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemsJson_AllHaveValidCategory()
|
|
{
|
|
var items = LoadItems();
|
|
// If deserialization succeeded with JsonStringEnumConverter, all categories are valid
|
|
Assert.All(items, item => Assert.True(Enum.IsDefined(item.Category),
|
|
$"Item '{item.Id}' has invalid category"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemsJson_AllHaveValidRarity()
|
|
{
|
|
var items = LoadItems();
|
|
Assert.All(items, item => Assert.True(Enum.IsDefined(item.Rarity),
|
|
$"Item '{item.Id}' has invalid rarity"));
|
|
}
|
|
|
|
// ── Boxes ────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void BoxesJson_Deserializes()
|
|
{
|
|
var json = File.ReadAllText(BoxesPath);
|
|
var boxes = JsonSerializer.Deserialize<List<BoxDefinition>>(json, JsonOptions);
|
|
|
|
Assert.NotNull(boxes);
|
|
Assert.NotEmpty(boxes);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxesJson_AllIdsAreUnique()
|
|
{
|
|
var boxes = LoadBoxes();
|
|
var duplicates = boxes.GroupBy(b => b.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
|
|
|
|
Assert.Empty(duplicates);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxesJson_GuaranteedRollsReferenceValidItems()
|
|
{
|
|
var items = LoadItems().Select(i => i.Id).ToHashSet();
|
|
var boxes = LoadBoxes();
|
|
var boxIds = boxes.Select(b => b.Id).ToHashSet();
|
|
|
|
var invalid = new List<string>();
|
|
foreach (var box in boxes)
|
|
{
|
|
foreach (var guaranteedId in box.LootTable.GuaranteedRolls)
|
|
{
|
|
if (!items.Contains(guaranteedId) && !boxIds.Contains(guaranteedId))
|
|
invalid.Add($"{box.Id} -> {guaranteedId}");
|
|
}
|
|
}
|
|
|
|
Assert.Empty(invalid);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxesJson_LootEntryItemsExist()
|
|
{
|
|
var items = LoadItems().Select(i => i.Id).ToHashSet();
|
|
var boxes = LoadBoxes();
|
|
var boxIds = boxes.Select(b => b.Id).ToHashSet();
|
|
|
|
var invalid = new List<string>();
|
|
foreach (var box in boxes)
|
|
{
|
|
foreach (var entry in box.LootTable.Entries)
|
|
{
|
|
if (!items.Contains(entry.ItemDefinitionId) && !boxIds.Contains(entry.ItemDefinitionId))
|
|
invalid.Add($"{box.Id} -> {entry.ItemDefinitionId}");
|
|
}
|
|
}
|
|
|
|
Assert.Empty(invalid);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxesJson_AllEntriesHavePositiveWeight()
|
|
{
|
|
var boxes = LoadBoxes();
|
|
var invalid = new List<string>();
|
|
foreach (var box in boxes)
|
|
{
|
|
foreach (var entry in box.LootTable.Entries)
|
|
{
|
|
if (entry.Weight <= 0)
|
|
invalid.Add($"{box.Id} -> {entry.ItemDefinitionId} (weight={entry.Weight})");
|
|
}
|
|
}
|
|
|
|
Assert.Empty(invalid);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxesJson_AllHaveEitherGuaranteedOrRollEntries()
|
|
{
|
|
var boxes = LoadBoxes();
|
|
var empty = boxes
|
|
.Where(b => b.LootTable.GuaranteedRolls.Count == 0
|
|
&& b.LootTable.Entries.Count == 0)
|
|
.Select(b => b.Id)
|
|
.ToList();
|
|
|
|
Assert.Empty(empty);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxesJson_LootConditionsHaveValidTypes()
|
|
{
|
|
var boxes = LoadBoxes();
|
|
// If deserialization with JsonStringEnumConverter worked, all condition types are valid.
|
|
// But let's also verify targetId makes sense for specific conditions.
|
|
var issues = new List<string>();
|
|
foreach (var box in boxes)
|
|
{
|
|
foreach (var entry in box.LootTable.Entries)
|
|
{
|
|
if (entry.Condition is not null)
|
|
{
|
|
Assert.True(Enum.IsDefined(entry.Condition.Type),
|
|
$"Box '{box.Id}' entry '{entry.ItemDefinitionId}' has invalid condition type");
|
|
|
|
if (entry.Condition.Type == LootConditionType.BoxesOpenedAbove
|
|
&& !entry.Condition.Value.HasValue)
|
|
{
|
|
issues.Add($"{box.Id}/{entry.ItemDefinitionId}: BoxesOpenedAbove needs a value");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert.Empty(issues);
|
|
}
|
|
|
|
// ── Interactions ─────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void InteractionsJson_Deserializes()
|
|
{
|
|
var json = File.ReadAllText(InteractionsPath);
|
|
var rules = JsonSerializer.Deserialize<List<InteractionRule>>(json, JsonOptions);
|
|
|
|
Assert.NotNull(rules);
|
|
Assert.NotEmpty(rules);
|
|
}
|
|
|
|
[Fact]
|
|
public void InteractionsJson_AllIdsAreUnique()
|
|
{
|
|
var rules = LoadInteractions();
|
|
var duplicates = rules.GroupBy(r => r.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
|
|
|
|
Assert.Empty(duplicates);
|
|
}
|
|
|
|
// ── Recipes ──────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void RecipesJson_Deserializes()
|
|
{
|
|
var json = File.ReadAllText(RecipesPath);
|
|
var doc = JsonDocument.Parse(json);
|
|
|
|
Assert.NotNull(doc);
|
|
Assert.True(doc.RootElement.GetArrayLength() > 0, "recipes.json is empty");
|
|
}
|
|
|
|
[Fact]
|
|
public void RecipesJson_AllIngredientsExist()
|
|
{
|
|
var items = LoadItems().Select(i => i.Id).ToHashSet();
|
|
var boxes = LoadBoxes().Select(b => b.Id).ToHashSet();
|
|
|
|
var json = File.ReadAllText(RecipesPath);
|
|
var doc = JsonDocument.Parse(json);
|
|
|
|
var invalid = new List<string>();
|
|
foreach (var recipe in doc.RootElement.EnumerateArray())
|
|
{
|
|
var recipeId = recipe.GetProperty("id").GetString()!;
|
|
foreach (var ingredient in recipe.GetProperty("ingredients").EnumerateArray())
|
|
{
|
|
var itemId = ingredient.GetProperty("itemDefinitionId").GetString()!;
|
|
if (!items.Contains(itemId) && !boxes.Contains(itemId))
|
|
invalid.Add($"{recipeId} -> ingredient '{itemId}'");
|
|
}
|
|
|
|
var resultId = recipe.GetProperty("result").GetProperty("itemDefinitionId").GetString()!;
|
|
if (!items.Contains(resultId) && !boxes.Contains(resultId))
|
|
invalid.Add($"{recipeId} -> result '{resultId}'");
|
|
}
|
|
|
|
Assert.Empty(invalid);
|
|
}
|
|
|
|
[Fact]
|
|
public void RecipesJson_AllWorkstationsAreValid()
|
|
{
|
|
var json = File.ReadAllText(RecipesPath);
|
|
var doc = JsonDocument.Parse(json);
|
|
|
|
var invalid = new List<string>();
|
|
foreach (var recipe in doc.RootElement.EnumerateArray())
|
|
{
|
|
var recipeId = recipe.GetProperty("id").GetString()!;
|
|
var workstation = recipe.GetProperty("workstation").GetString()!;
|
|
if (!Enum.TryParse<WorkstationType>(workstation, out _))
|
|
invalid.Add($"{recipeId} -> workstation '{workstation}'");
|
|
}
|
|
|
|
Assert.Empty(invalid);
|
|
}
|
|
|
|
// ── Localization ─────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void EnStrings_IsValidJson()
|
|
{
|
|
var json = File.ReadAllText(EnStringsPath);
|
|
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
|
|
|
Assert.NotNull(dict);
|
|
Assert.NotEmpty(dict);
|
|
}
|
|
|
|
[Fact]
|
|
public void FrStrings_IsValidJson()
|
|
{
|
|
var json = File.ReadAllText(FrStringsPath);
|
|
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
|
|
|
Assert.NotNull(dict);
|
|
Assert.NotEmpty(dict);
|
|
}
|
|
|
|
[Fact]
|
|
public void FrStrings_HasAllKeysFromEn()
|
|
{
|
|
var enJson = File.ReadAllText(EnStringsPath);
|
|
var en = JsonSerializer.Deserialize<Dictionary<string, string>>(enJson)!;
|
|
var frJson = File.ReadAllText(FrStringsPath);
|
|
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
|
|
|
|
var missing = en.Keys.Where(k => !fr.ContainsKey(k)).ToList();
|
|
|
|
Assert.Empty(missing);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemNameKeys_ExistInLocalization()
|
|
{
|
|
var items = LoadItems();
|
|
var en = LoadEnStrings();
|
|
|
|
var missing = items
|
|
.Where(i => !en.ContainsKey(i.NameKey))
|
|
.Select(i => $"{i.Id} -> nameKey '{i.NameKey}'")
|
|
.ToList();
|
|
|
|
Assert.Empty(missing);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxNameKeys_ExistInLocalization()
|
|
{
|
|
var boxes = LoadBoxes();
|
|
var en = LoadEnStrings();
|
|
|
|
var missing = boxes
|
|
.Where(b => !en.ContainsKey(b.NameKey))
|
|
.Select(b => $"{b.Id} -> nameKey '{b.NameKey}'")
|
|
.ToList();
|
|
|
|
Assert.Empty(missing);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxDescriptionKeys_ExistInLocalization()
|
|
{
|
|
var boxes = LoadBoxes();
|
|
var en = LoadEnStrings();
|
|
|
|
var missing = boxes
|
|
.Where(b => !string.IsNullOrEmpty(b.DescriptionKey) && !en.ContainsKey(b.DescriptionKey))
|
|
.Select(b => $"{b.Id} -> descriptionKey '{b.DescriptionKey}'")
|
|
.ToList();
|
|
|
|
Assert.Empty(missing);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemNameKeys_ExistInFrLocalization()
|
|
{
|
|
var items = LoadItems();
|
|
var frJson = File.ReadAllText(FrStringsPath);
|
|
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
|
|
|
|
var missing = items
|
|
.Where(i => !fr.ContainsKey(i.NameKey))
|
|
.Select(i => $"{i.Id} -> nameKey '{i.NameKey}'")
|
|
.ToList();
|
|
|
|
Assert.Empty(missing);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxNameKeys_ExistInFrLocalization()
|
|
{
|
|
var boxes = LoadBoxes();
|
|
var frJson = File.ReadAllText(FrStringsPath);
|
|
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
|
|
|
|
var missing = boxes
|
|
.Where(b => !fr.ContainsKey(b.NameKey))
|
|
.Select(b => $"{b.Id} -> nameKey '{b.NameKey}'")
|
|
.ToList();
|
|
|
|
Assert.Empty(missing);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxDescriptionKeys_ExistInFrLocalization()
|
|
{
|
|
var boxes = LoadBoxes();
|
|
var frJson = File.ReadAllText(FrStringsPath);
|
|
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
|
|
|
|
var missing = boxes
|
|
.Where(b => !string.IsNullOrEmpty(b.DescriptionKey) && !fr.ContainsKey(b.DescriptionKey))
|
|
.Select(b => $"{b.Id} -> descriptionKey '{b.DescriptionKey}'")
|
|
.ToList();
|
|
|
|
Assert.Empty(missing);
|
|
}
|
|
|
|
[Fact]
|
|
public void AllUIFeatures_HaveLocalizationKeys()
|
|
{
|
|
var en = LoadEnStrings();
|
|
|
|
// Map of UIFeature -> expected localization key
|
|
var featureKeys = new Dictionary<UIFeature, string>
|
|
{
|
|
[UIFeature.TextColors] = "meta.colors",
|
|
[UIFeature.ExtendedColors] = "meta.extended_colors",
|
|
[UIFeature.ArrowKeySelection] = "meta.arrows",
|
|
[UIFeature.InventoryPanel] = "meta.inventory",
|
|
[UIFeature.ResourcePanel] = "meta.resources",
|
|
[UIFeature.StatsPanel] = "meta.stats",
|
|
[UIFeature.PortraitPanel] = "meta.portrait",
|
|
[UIFeature.ChatPanel] = "meta.chat",
|
|
[UIFeature.FullLayout] = "meta.layout",
|
|
[UIFeature.KeyboardShortcuts] = "meta.shortcuts",
|
|
[UIFeature.BoxAnimation] = "meta.animation",
|
|
[UIFeature.CraftingPanel] = "meta.crafting",
|
|
[UIFeature.CompletionTracker] = "meta.completion",
|
|
[UIFeature.AutoSave] = "meta.autosave",
|
|
};
|
|
|
|
var missing = featureKeys
|
|
.Where(kv => !en.ContainsKey(kv.Value))
|
|
.Select(kv => $"{kv.Key} -> '{kv.Value}'")
|
|
.ToList();
|
|
|
|
Assert.Empty(missing);
|
|
|
|
// Also verify every UIFeature enum value has a mapping
|
|
var allFeatures = Enum.GetValues<UIFeature>();
|
|
var unmapped = allFeatures.Where(f => !featureKeys.ContainsKey(f)).ToList();
|
|
Assert.Empty(unmapped);
|
|
}
|
|
|
|
[Fact]
|
|
public void AllMetaUnlockItems_ReferenceValidUIFeatures()
|
|
{
|
|
var items = LoadItems();
|
|
var metaItems = items.Where(i => i.MetaUnlock.HasValue).ToList();
|
|
|
|
// Every meta item's MetaUnlock value should be a valid UIFeature
|
|
// (this is guaranteed by deserialization, but let's verify the round-trip)
|
|
Assert.All(metaItems, item =>
|
|
Assert.True(Enum.IsDefined(item.MetaUnlock!.Value),
|
|
$"Item {item.Id} has invalid metaUnlock value"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CosmeticSlots_HaveLocalizationKeys()
|
|
{
|
|
var en = LoadEnStrings();
|
|
var items = LoadItems();
|
|
|
|
// Every CosmeticSlot used in items should have a cosmetic.slot.{slot} key
|
|
var cosmeticSlots = items
|
|
.Where(i => i.CosmeticSlot.HasValue)
|
|
.Select(i => i.CosmeticSlot!.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
var missingSlotKeys = cosmeticSlots
|
|
.Select(s => $"cosmetic.slot.{s.ToString().ToLower()}")
|
|
.Where(k => !en.ContainsKey(k))
|
|
.ToList();
|
|
|
|
Assert.Empty(missingSlotKeys);
|
|
}
|
|
|
|
[Fact]
|
|
public void CosmeticItems_CanBeResolvedBySlotAndValue()
|
|
{
|
|
// Verifies the lookup pattern used in ChangeAppearance():
|
|
// For every cosmetic item, a lookup by (CosmeticSlot, CosmeticValue) must find the item
|
|
var items = LoadItems();
|
|
var cosmeticItems = items
|
|
.Where(i => i.CosmeticSlot.HasValue && i.CosmeticValue is not null)
|
|
.ToList();
|
|
|
|
// Check for duplicate (slot, value) pairs which would cause ambiguous lookups
|
|
var duplicates = cosmeticItems
|
|
.GroupBy(i => (i.CosmeticSlot, Value: i.CosmeticValue!.ToLower()))
|
|
.Where(g => g.Count() > 1)
|
|
.Select(g => $"({g.Key.CosmeticSlot}, {g.Key.Value}): [{string.Join(", ", g.Select(i => i.Id))}]")
|
|
.ToList();
|
|
|
|
Assert.Empty(duplicates);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnAndFr_HaveIdenticalKeysets()
|
|
{
|
|
var en = LoadEnStrings();
|
|
var frJson = File.ReadAllText(FrStringsPath);
|
|
var fr = JsonSerializer.Deserialize<Dictionary<string, string>>(frJson)!;
|
|
|
|
var onlyInEn = en.Keys.Where(k => !fr.ContainsKey(k)).ToList();
|
|
var onlyInFr = fr.Keys.Where(k => !en.ContainsKey(k)).ToList();
|
|
|
|
Assert.Empty(onlyInEn);
|
|
Assert.Empty(onlyInFr);
|
|
}
|
|
|
|
// ── ContentRegistry integration ──────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ContentRegistry_LoadsSuccessfully()
|
|
{
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
|
|
|
Assert.True(registry.Items.Count > 0, "No items loaded");
|
|
Assert.True(registry.Boxes.Count > 0, "No boxes loaded");
|
|
Assert.True(registry.InteractionRules.Count > 0, "No interaction rules loaded");
|
|
}
|
|
|
|
[Fact]
|
|
public void ContentRegistry_StarterBoxExists()
|
|
{
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
|
var starter = registry.GetBox("box_starter");
|
|
|
|
Assert.NotNull(starter);
|
|
Assert.NotEmpty(starter.LootTable.GuaranteedRolls);
|
|
}
|
|
|
|
// ── Simulation smoke test ────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Simulation_OpenStarterBox_ProducesEvents()
|
|
{
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
|
var simulation = new GameSimulation(registry, new Random(42));
|
|
var state = GameState.Create("TestPlayer", Locale.EN);
|
|
|
|
// Give the player a starter box
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" };
|
|
var events = simulation.ProcessAction(action, state);
|
|
|
|
Assert.NotEmpty(events);
|
|
Assert.Contains(events, e => e is BoxOpenedEvent);
|
|
Assert.Contains(events, e => e is ItemReceivedEvent);
|
|
}
|
|
|
|
// ── Black Box invariant: simulation owns all state mutations ─────────
|
|
|
|
[Fact]
|
|
public void Simulation_ProcessAction_HandlesAllStateMutations()
|
|
{
|
|
// The simulation (ProcessAction) must be the SOLE mutator of GameState.
|
|
// After calling ProcessAction, the inventory should already reflect all
|
|
// items received and consumed. External code (game loop, renderer) must
|
|
// NOT call AddItem/RemoveItem — that would cause duplicates.
|
|
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
|
var simulation = new GameSimulation(registry, new Random(42));
|
|
var state = GameState.Create("TestPlayer", Locale.EN);
|
|
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
int inventoryBefore = state.Inventory.Count; // 1 (the starter box)
|
|
|
|
var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" };
|
|
var events = simulation.ProcessAction(action, state);
|
|
|
|
// Count events
|
|
int received = events.OfType<ItemReceivedEvent>().Count();
|
|
int consumed = events.OfType<ItemConsumedEvent>().Count();
|
|
|
|
// The simulation already mutated state. Verify the inventory matches
|
|
// exactly: initial - consumed + received
|
|
int expectedCount = inventoryBefore - consumed + received;
|
|
Assert.Equal(expectedCount, state.Inventory.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Simulation_NoDuplicateItemInstances_InInventory()
|
|
{
|
|
// Each ItemInstance has a unique Guid. After opening a box,
|
|
// no two inventory entries should share the same Id.
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
|
var simulation = new GameSimulation(registry, new Random(42));
|
|
var state = GameState.Create("TestPlayer", Locale.EN);
|
|
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" };
|
|
simulation.ProcessAction(action, state);
|
|
|
|
var duplicateIds = state.Inventory
|
|
.GroupBy(i => i.Id)
|
|
.Where(g => g.Count() > 1)
|
|
.Select(g => g.Key)
|
|
.ToList();
|
|
|
|
Assert.Empty(duplicateIds);
|
|
}
|
|
|
|
// ── Adventures ───────────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("space")]
|
|
[InlineData("medieval")]
|
|
[InlineData("pirate")]
|
|
[InlineData("contemporary")]
|
|
[InlineData("sentimental")]
|
|
[InlineData("prehistoric")]
|
|
[InlineData("cosmic")]
|
|
[InlineData("microscopic")]
|
|
[InlineData("darkfantasy")]
|
|
public void Adventure_ScriptFileExists(string theme)
|
|
{
|
|
var path = Path.Combine(ContentRoot, "adventures", theme, "intro.lor");
|
|
Assert.True(File.Exists(path), $"Missing adventure script: {path}");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("space")]
|
|
[InlineData("medieval")]
|
|
[InlineData("pirate")]
|
|
[InlineData("contemporary")]
|
|
[InlineData("sentimental")]
|
|
[InlineData("prehistoric")]
|
|
[InlineData("cosmic")]
|
|
[InlineData("microscopic")]
|
|
[InlineData("darkfantasy")]
|
|
public void Adventure_FrenchTranslationExists(string theme)
|
|
{
|
|
var path = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor");
|
|
Assert.True(File.Exists(path), $"Missing French translation: {path}");
|
|
}
|
|
|
|
// ── Adventure script parsing ───────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("space")]
|
|
[InlineData("medieval")]
|
|
[InlineData("pirate")]
|
|
[InlineData("contemporary")]
|
|
[InlineData("sentimental")]
|
|
[InlineData("prehistoric")]
|
|
[InlineData("cosmic")]
|
|
[InlineData("microscopic")]
|
|
[InlineData("darkfantasy")]
|
|
[InlineData("destiny")]
|
|
public void Adventure_ScriptParsesWithoutError(string theme)
|
|
{
|
|
var path = Path.Combine(ContentRoot, "adventures", theme, "intro.lor");
|
|
Assert.True(File.Exists(path), $"Missing adventure script: {path}");
|
|
|
|
string content = File.ReadAllText(path);
|
|
|
|
var exception = Record.Exception(() =>
|
|
{
|
|
Script script = Engine.Parse(
|
|
content,
|
|
path,
|
|
(importPath, callback) =>
|
|
{
|
|
string dir = Path.GetDirectoryName(path) ?? ".";
|
|
string fullPath = Path.Combine(dir, importPath);
|
|
callback(File.Exists(fullPath) ? File.ReadAllText(fullPath) : string.Empty);
|
|
});
|
|
|
|
Assert.NotNull(script);
|
|
});
|
|
|
|
Assert.Null(exception);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("space")]
|
|
[InlineData("medieval")]
|
|
[InlineData("pirate")]
|
|
[InlineData("contemporary")]
|
|
[InlineData("sentimental")]
|
|
[InlineData("prehistoric")]
|
|
[InlineData("cosmic")]
|
|
[InlineData("microscopic")]
|
|
[InlineData("darkfantasy")]
|
|
public void Adventure_FrenchTranslationParsesWithoutError(string theme)
|
|
{
|
|
var path = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor");
|
|
Assert.True(File.Exists(path), $"Missing French translation: {path}");
|
|
|
|
string content = File.ReadAllText(path);
|
|
|
|
var exception = Record.Exception(() =>
|
|
{
|
|
Script script = Engine.Parse(content);
|
|
Assert.NotNull(script);
|
|
});
|
|
|
|
Assert.Null(exception);
|
|
}
|
|
|
|
// ── Full run integration tests ─────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(42)]
|
|
[InlineData(123)]
|
|
[InlineData(777)]
|
|
public void FullRun_AllReachableContentIsObtained(int seed)
|
|
{
|
|
// Simulates an entire game playthrough by repeatedly opening boxes
|
|
// until all content reachable via box openings + crafting is unlocked.
|
|
// Uses only the simulation (zero I/O) to prove game completability.
|
|
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
|
|
var simulation = new GameSimulation(registry, new Random(seed));
|
|
var craftingEngine = new CraftingEngine();
|
|
var state = GameState.Create("CompletionTest", Locale.EN);
|
|
|
|
// ── Compute the "reachable set" dynamically from item definitions ──
|
|
|
|
var allItems = registry.Items.Values.ToList();
|
|
|
|
var expectedUIFeatures = allItems
|
|
.Where(i => i.MetaUnlock.HasValue)
|
|
.Select(i => i.MetaUnlock!.Value)
|
|
.ToHashSet();
|
|
|
|
var expectedCosmetics = allItems
|
|
.Where(i => i.CosmeticSlot.HasValue)
|
|
.Select(i => i.Id)
|
|
.ToHashSet();
|
|
|
|
var expectedAdventures = allItems
|
|
.Where(i => i.AdventureTheme.HasValue)
|
|
.Select(i => i.AdventureTheme!.Value)
|
|
.ToHashSet();
|
|
|
|
var expectedResources = allItems
|
|
.Where(i => i.ResourceType.HasValue)
|
|
.Select(i => i.ResourceType!.Value)
|
|
.ToHashSet();
|
|
|
|
var expectedLore = allItems
|
|
.Where(i => i.Category == ItemCategory.LoreFragment)
|
|
.Select(i => i.Id)
|
|
.ToHashSet();
|
|
|
|
var expectedStats = allItems
|
|
.Where(i => i.StatType.HasValue)
|
|
.Select(i => i.StatType!.Value)
|
|
.ToHashSet();
|
|
|
|
var expectedFonts = allItems
|
|
.Where(i => i.FontStyle.HasValue)
|
|
.Select(i => i.FontStyle!.Value)
|
|
.ToHashSet();
|
|
|
|
// Adventure token recipes: their outputs are also direct drops from adventure boxes,
|
|
// so they serve as bonus insurance, not mandatory crafting requirements.
|
|
var adventureRecipeIds = new HashSet<string>
|
|
{
|
|
"chart_star_navigation", "engrave_royal_seal", "enchant_dark_grimoire",
|
|
"fuse_cosmic_crystal", "splice_glowing_dna", "preserve_amber"
|
|
};
|
|
|
|
// Core recipe output IDs → required crafted items (only obtainable via crafting)
|
|
var expectedCraftedItems = registry.Recipes.Values
|
|
.Where(r => !adventureRecipeIds.Contains(r.Id))
|
|
.Select(r => r.Result.ItemDefinitionId)
|
|
.ToHashSet();
|
|
|
|
// Track all unique item definition IDs ever received
|
|
var seenDefinitionIds = new HashSet<string>();
|
|
|
|
// ── Give starter box and run the game loop ──
|
|
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
const int maxBoxOpenings = 10_000;
|
|
int totalBoxesOpened = 0;
|
|
|
|
for (int i = 0; i < maxBoxOpenings; i++)
|
|
{
|
|
// Pick one box from inventory
|
|
var box = state.Inventory
|
|
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
|
|
|
|
if (box is null)
|
|
break;
|
|
|
|
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
|
|
var events = simulation.ProcessAction(action, state);
|
|
|
|
foreach (var evt in events.OfType<ItemReceivedEvent>())
|
|
seenDefinitionIds.Add(evt.Item.DefinitionId);
|
|
|
|
// ── Fast-forward crafting: complete all jobs through full cascade ──
|
|
bool keepCrafting;
|
|
do
|
|
{
|
|
foreach (var job in state.ActiveCraftingJobs
|
|
.Where(j => j.Status == CraftingJobStatus.InProgress))
|
|
job.StartedAt = DateTime.UtcNow.AddHours(-1);
|
|
craftingEngine.TickJobs(state);
|
|
var coll = craftingEngine.CollectCompleted(state, registry);
|
|
foreach (var evt in coll.OfType<ItemReceivedEvent>())
|
|
seenDefinitionIds.Add(evt.Item.DefinitionId);
|
|
var cascade = craftingEngine.AutoCraftCheck(state, registry);
|
|
keepCrafting = cascade.OfType<CraftingStartedEvent>().Any();
|
|
} while (keepCrafting);
|
|
|
|
totalBoxesOpened++;
|
|
|
|
// Check if we've covered everything (including crafted items)
|
|
bool allUIFeatures = expectedUIFeatures.IsSubsetOf(state.UnlockedUIFeatures);
|
|
bool allCosmetics = expectedCosmetics.IsSubsetOf(state.UnlockedCosmetics);
|
|
bool allAdventures = expectedAdventures.IsSubsetOf(state.UnlockedAdventures);
|
|
bool allResources = expectedResources.IsSubsetOf(state.VisibleResources);
|
|
bool allLore = expectedLore.IsSubsetOf(seenDefinitionIds);
|
|
bool allStats = expectedStats.IsSubsetOf(state.VisibleStats);
|
|
bool allFonts = expectedFonts.IsSubsetOf(state.AvailableFonts);
|
|
bool allCrafted = expectedCraftedItems.IsSubsetOf(seenDefinitionIds);
|
|
|
|
if (allUIFeatures && allCosmetics && allAdventures && allResources
|
|
&& allLore && allStats && allFonts && allCrafted)
|
|
break; // 100% completion reached
|
|
}
|
|
|
|
// ── Assertions with detailed failure messages ──
|
|
|
|
var missingUIFeatures = expectedUIFeatures.Except(state.UnlockedUIFeatures).ToList();
|
|
Assert.True(missingUIFeatures.Count == 0,
|
|
$"Missing UI features after {totalBoxesOpened} boxes: {string.Join(", ", missingUIFeatures)}");
|
|
|
|
var missingCosmetics = expectedCosmetics.Except(state.UnlockedCosmetics).ToList();
|
|
Assert.True(missingCosmetics.Count == 0,
|
|
$"Missing cosmetics after {totalBoxesOpened} boxes: {string.Join(", ", missingCosmetics)}");
|
|
|
|
var missingAdventures = expectedAdventures.Except(state.UnlockedAdventures).ToList();
|
|
Assert.True(missingAdventures.Count == 0,
|
|
$"Missing adventures after {totalBoxesOpened} boxes: {string.Join(", ", missingAdventures)}");
|
|
|
|
var missingResources = expectedResources.Except(state.VisibleResources).ToList();
|
|
Assert.True(missingResources.Count == 0,
|
|
$"Missing visible resources after {totalBoxesOpened} boxes: {string.Join(", ", missingResources)}");
|
|
|
|
var missingLore = expectedLore.Except(seenDefinitionIds).ToList();
|
|
Assert.True(missingLore.Count == 0,
|
|
$"Missing lore fragments after {totalBoxesOpened} boxes: {string.Join(", ", missingLore)}");
|
|
|
|
var missingStats = expectedStats.Except(state.VisibleStats).ToList();
|
|
Assert.True(missingStats.Count == 0,
|
|
$"Missing visible stats after {totalBoxesOpened} boxes: {string.Join(", ", missingStats)}");
|
|
|
|
var missingFonts = expectedFonts.Except(state.AvailableFonts).ToList();
|
|
Assert.True(missingFonts.Count == 0,
|
|
$"Missing fonts after {totalBoxesOpened} boxes: {string.Join(", ", missingFonts)}");
|
|
|
|
var missingCrafted = expectedCraftedItems.Except(seenDefinitionIds).ToList();
|
|
Assert.True(missingCrafted.Count == 0,
|
|
$"Missing crafted items after {totalBoxesOpened} boxes: {string.Join(", ", missingCrafted)}");
|
|
}
|
|
|
|
[Fact]
|
|
public void FullRun_GameLoopNeverBreaks()
|
|
{
|
|
// After 500 box openings, the player must still have at least 1 box
|
|
// in inventory. This validates the box_of_boxes guaranteed roll
|
|
// sustains the game loop indefinitely.
|
|
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
|
|
var simulation = new GameSimulation(registry, new Random(123));
|
|
var state = GameState.Create("LoopTest", Locale.EN);
|
|
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
for (int i = 0; i < 500; i++)
|
|
{
|
|
var box = state.Inventory
|
|
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
|
|
|
|
Assert.True(box is not null,
|
|
$"No boxes left in inventory after opening {i} boxes. Game loop is broken.");
|
|
|
|
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
|
|
simulation.ProcessAction(action, state);
|
|
}
|
|
|
|
// After 500 openings, player should still have boxes
|
|
var remainingBoxes = state.Inventory.Count(i => registry.IsBox(i.DefinitionId));
|
|
Assert.True(remainingBoxes > 0,
|
|
"No boxes remaining after 500 openings. Game loop is unsustainable.");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(42)]
|
|
[InlineData(123)]
|
|
[InlineData(777)]
|
|
public void FullRun_PacingReport(int seed)
|
|
{
|
|
// Diagnostic test: outputs a pacing report showing when each piece
|
|
// of content is first unlocked, including crafting progression.
|
|
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
|
|
var simulation = new GameSimulation(registry, new Random(seed));
|
|
var craftingEngine = new CraftingEngine();
|
|
var state = GameState.Create("PacingTest", Locale.EN);
|
|
|
|
var allItems = registry.Items.Values.ToList();
|
|
|
|
var expectedUIFeatures = allItems
|
|
.Where(i => i.MetaUnlock.HasValue)
|
|
.Select(i => i.MetaUnlock!.Value)
|
|
.ToHashSet();
|
|
var expectedCosmetics = allItems
|
|
.Where(i => i.CosmeticSlot.HasValue)
|
|
.Select(i => i.Id)
|
|
.ToHashSet();
|
|
var expectedAdventures = allItems
|
|
.Where(i => i.AdventureTheme.HasValue)
|
|
.Select(i => i.AdventureTheme!.Value)
|
|
.ToHashSet();
|
|
var expectedResources = allItems
|
|
.Where(i => i.ResourceType.HasValue)
|
|
.Select(i => i.ResourceType!.Value)
|
|
.ToHashSet();
|
|
var expectedLore = allItems
|
|
.Where(i => i.Category == ItemCategory.LoreFragment)
|
|
.Select(i => i.Id)
|
|
.ToHashSet();
|
|
|
|
// Expected workstation blueprints (items with WorkstationType set)
|
|
var expectedBlueprints = allItems
|
|
.Where(i => i.WorkstationType.HasValue)
|
|
.Select(i => i.Id)
|
|
.ToHashSet();
|
|
|
|
// Adventure token recipes (bonus, not required for completion)
|
|
var adventureRecipeIds = new HashSet<string>
|
|
{
|
|
"chart_star_navigation", "engrave_royal_seal", "enchant_dark_grimoire",
|
|
"fuse_cosmic_crystal", "splice_glowing_dna", "preserve_amber"
|
|
};
|
|
|
|
// Expected crafted outputs (core recipes only — adventure outputs drop directly from boxes)
|
|
var expectedCraftedItems = registry.Recipes.Values
|
|
.Where(r => !adventureRecipeIds.Contains(r.Id))
|
|
.Select(r => r.Result.ItemDefinitionId)
|
|
.ToHashSet();
|
|
|
|
var seenDefinitionIds = new HashSet<string>();
|
|
|
|
// Track unlock milestones: (box#, description)
|
|
var milestones = new List<(int boxNum, string description)>();
|
|
|
|
// Track previous counts to detect new unlocks
|
|
int prevUI = 0, prevCos = 0, prevAdv = 0, prevRes = 0, prevLore = 0, prevBP = 0, prevCraft = 0;
|
|
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
const int maxBoxOpenings = 10_000;
|
|
int totalBoxesOpened = 0;
|
|
bool complete = false;
|
|
|
|
for (int i = 0; i < maxBoxOpenings && !complete; i++)
|
|
{
|
|
var box = state.Inventory
|
|
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
|
|
if (box is null) break;
|
|
|
|
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
|
|
var events = simulation.ProcessAction(action, state);
|
|
|
|
foreach (var evt in events.OfType<ItemReceivedEvent>())
|
|
seenDefinitionIds.Add(evt.Item.DefinitionId);
|
|
|
|
// ── Fast-forward crafting through full cascade ──
|
|
bool keepCrafting;
|
|
do
|
|
{
|
|
foreach (var job in state.ActiveCraftingJobs
|
|
.Where(j => j.Status == CraftingJobStatus.InProgress))
|
|
job.StartedAt = DateTime.UtcNow.AddHours(-1);
|
|
craftingEngine.TickJobs(state);
|
|
var coll = craftingEngine.CollectCompleted(state, registry);
|
|
foreach (var evt in coll.OfType<ItemReceivedEvent>())
|
|
seenDefinitionIds.Add(evt.Item.DefinitionId);
|
|
var cascade = craftingEngine.AutoCraftCheck(state, registry);
|
|
keepCrafting = cascade.OfType<CraftingStartedEvent>().Any();
|
|
} while (keepCrafting);
|
|
|
|
totalBoxesOpened++;
|
|
|
|
// Detect new unlocks
|
|
int curUI = state.UnlockedUIFeatures.Count(f => expectedUIFeatures.Contains(f));
|
|
int curCos = state.UnlockedCosmetics.Count(c => expectedCosmetics.Contains(c));
|
|
int curAdv = state.UnlockedAdventures.Count(a => expectedAdventures.Contains(a));
|
|
int curRes = state.VisibleResources.Count(r => expectedResources.Contains(r));
|
|
int curLore = seenDefinitionIds.Count(id => expectedLore.Contains(id));
|
|
int curBP = state.UnlockedWorkstations.Count;
|
|
int curCraft = seenDefinitionIds.Count(id => expectedCraftedItems.Contains(id));
|
|
|
|
if (curUI > prevUI)
|
|
{
|
|
var newFeatures = state.UnlockedUIFeatures.Where(f => expectedUIFeatures.Contains(f)).ToList();
|
|
milestones.Add((totalBoxesOpened, $"UI Feature {curUI}/{expectedUIFeatures.Count}: {newFeatures.Last()}"));
|
|
}
|
|
if (curBP > prevBP)
|
|
{
|
|
var newStation = state.UnlockedWorkstations.Last();
|
|
milestones.Add((totalBoxesOpened, $"Workshop {curBP}/{expectedBlueprints.Count}: {newStation}"));
|
|
}
|
|
if (curCraft > prevCraft)
|
|
milestones.Add((totalBoxesOpened, $"Crafted: {curCraft}/{expectedCraftedItems.Count}"));
|
|
if (curCos > prevCos)
|
|
milestones.Add((totalBoxesOpened, $"Cosmetics: {curCos}/{expectedCosmetics.Count}"));
|
|
if (curAdv > prevAdv)
|
|
milestones.Add((totalBoxesOpened, $"Adventures: {curAdv}/{expectedAdventures.Count}"));
|
|
if (curRes > prevRes)
|
|
milestones.Add((totalBoxesOpened, $"Resources: {curRes}/{expectedResources.Count}"));
|
|
if (curLore > prevLore)
|
|
milestones.Add((totalBoxesOpened, $"Lore: {curLore}/{expectedLore.Count}"));
|
|
|
|
prevUI = curUI; prevCos = curCos; prevAdv = curAdv;
|
|
prevRes = curRes; prevLore = curLore; prevBP = curBP; prevCraft = curCraft;
|
|
|
|
complete = curUI == expectedUIFeatures.Count
|
|
&& curCos == expectedCosmetics.Count
|
|
&& curAdv == expectedAdventures.Count
|
|
&& curRes == expectedResources.Count
|
|
&& curLore == expectedLore.Count
|
|
&& curCraft == expectedCraftedItems.Count;
|
|
}
|
|
|
|
// Build the pacing report
|
|
var report = new System.Text.StringBuilder();
|
|
report.AppendLine();
|
|
report.AppendLine("╔════════════════════════════════════════════════════════════╗");
|
|
report.AppendLine($"║ PACING REPORT (seed={seed,-6}) ║");
|
|
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
|
|
report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-38}║");
|
|
report.AppendLine($"║ Game completed: {(complete ? "YES" : "NO"),-42}║");
|
|
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
|
|
report.AppendLine("║ Box# │ Milestone ║");
|
|
report.AppendLine("╟────────┼──────────────────────────────────────────────────╢");
|
|
foreach (var (boxNum, desc) in milestones)
|
|
{
|
|
report.AppendLine($"║ {boxNum,5} │ {desc,-48} ║");
|
|
}
|
|
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
|
|
|
|
// Summary by category with completion box#
|
|
int uiDoneAt = milestones.LastOrDefault(m => m.description.Contains($"UI Feature {expectedUIFeatures.Count}/")).boxNum;
|
|
int cosDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Cosmetics: {expectedCosmetics.Count}/")).boxNum;
|
|
int advDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Adventures: {expectedAdventures.Count}/")).boxNum;
|
|
int resDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Resources: {expectedResources.Count}/")).boxNum;
|
|
int loreDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Lore: {expectedLore.Count}/")).boxNum;
|
|
int bpDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Workshop {expectedBlueprints.Count}/")).boxNum;
|
|
int craftDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Crafted: {expectedCraftedItems.Count}/")).boxNum;
|
|
|
|
report.AppendLine($"║ UI Features ({expectedUIFeatures.Count,2}) complete at box #{uiDoneAt,-23}║");
|
|
report.AppendLine($"║ Workshops ({expectedBlueprints.Count,2}) complete at box #{bpDoneAt,-23}║");
|
|
report.AppendLine($"║ Crafted ({expectedCraftedItems.Count,2}) complete at box #{craftDoneAt,-23}║");
|
|
report.AppendLine($"║ Cosmetics ({expectedCosmetics.Count,2}) complete at box #{cosDoneAt,-23}║");
|
|
report.AppendLine($"║ Adventures ({expectedAdventures.Count,2}) complete at box #{advDoneAt,-23}║");
|
|
report.AppendLine($"║ Resources ({expectedResources.Count,2}) complete at box #{resDoneAt,-23}║");
|
|
report.AppendLine($"║ Lore ({expectedLore.Count,2}) complete at box #{loreDoneAt,-23}║");
|
|
report.AppendLine("╚════════════════════════════════════════════════════════════╝");
|
|
|
|
Assert.True(true, report.ToString());
|
|
Console.WriteLine(report.ToString());
|
|
}
|
|
|
|
// ── Ultimate Ending Test ────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Simulates a full playthrough where the player completes all adventures,
|
|
/// discovers all 9 secret branches, obtains the destiny token from box_endgame,
|
|
/// and verifies the ultimate ending is achievable.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(42)]
|
|
[InlineData(123)]
|
|
[InlineData(777)]
|
|
public void FullRun_UltimateEnding_Achievable(int seed)
|
|
{
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
|
|
var simulation = new GameSimulation(registry, new Random(seed));
|
|
var craftingEngine = new CraftingEngine();
|
|
var state = GameState.Create("UltimateTest", Locale.EN);
|
|
|
|
// All 9 secret branch IDs matching the adventure .lor scripts
|
|
var allSecretBranches = new[]
|
|
{
|
|
"space_box_whisperer",
|
|
"medieval_dragon_charmer",
|
|
"pirate_one_of_us",
|
|
"contemporary_vip",
|
|
"sentimental_true_sight",
|
|
"prehistoric_champion",
|
|
"cosmic_enlightened",
|
|
"microscopic_surgeon",
|
|
"darkfantasy_blood_communion"
|
|
};
|
|
|
|
// All 9 regular adventure themes (excluding Destiny)
|
|
var regularThemes = Enum.GetValues<AdventureTheme>()
|
|
.Where(t => t != AdventureTheme.Destiny)
|
|
.ToList();
|
|
|
|
// Phase 1: Open boxes until all resources are visible (triggers box_endgame availability)
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
const int maxBoxOpenings = 10_000;
|
|
bool gotDestinyToken = false;
|
|
|
|
for (int i = 0; i < maxBoxOpenings; i++)
|
|
{
|
|
var box = state.Inventory
|
|
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
|
|
if (box is null) break;
|
|
|
|
var events = simulation.ProcessAction(
|
|
new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }, state);
|
|
|
|
// Check if we received the destiny token
|
|
foreach (var evt in events.OfType<ItemReceivedEvent>())
|
|
{
|
|
if (evt.Item.DefinitionId == "destiny_token")
|
|
gotDestinyToken = true;
|
|
}
|
|
|
|
// Fast-forward crafting
|
|
bool keepCrafting;
|
|
do
|
|
{
|
|
foreach (var job in state.ActiveCraftingJobs
|
|
.Where(j => j.Status == CraftingJobStatus.InProgress))
|
|
job.StartedAt = DateTime.UtcNow.AddHours(-1);
|
|
craftingEngine.TickJobs(state);
|
|
craftingEngine.CollectCompleted(state, registry);
|
|
var cascade = craftingEngine.AutoCraftCheck(state, registry);
|
|
keepCrafting = cascade.OfType<CraftingStartedEvent>().Any();
|
|
} while (keepCrafting);
|
|
}
|
|
|
|
// Phase 2: Verify destiny token was obtained
|
|
Assert.True(gotDestinyToken,
|
|
"destiny_token should be obtainable from box_endgame after all resources are visible");
|
|
|
|
// Verify Destiny adventure is unlocked
|
|
Assert.Contains(AdventureTheme.Destiny, state.UnlockedAdventures);
|
|
|
|
// Phase 3: Simulate completing all regular adventures with secret branches
|
|
foreach (var theme in regularThemes)
|
|
{
|
|
state.CompletedAdventures.Add(theme.ToString());
|
|
}
|
|
|
|
foreach (var branchId in allSecretBranches)
|
|
{
|
|
state.CompletedSecretBranches.Add(branchId);
|
|
}
|
|
|
|
// Phase 4: Verify ultimate ending conditions
|
|
Assert.Equal(9, state.CompletedSecretBranches.Count);
|
|
Assert.Equal(regularThemes.Count, state.CompletedAdventures.Count);
|
|
Assert.True(
|
|
state.CompletedSecretBranches.Count >= Adventures.AdventureEngine.TotalSecretBranchThemes,
|
|
$"All {Adventures.AdventureEngine.TotalSecretBranchThemes} secret branches should be found " +
|
|
$"(got {state.CompletedSecretBranches.Count})");
|
|
|
|
// Phase 5: Verify the destiny adventure script exists
|
|
string destinyScript = Path.Combine("content", "adventures", "destiny", "intro.lor");
|
|
Assert.True(File.Exists(destinyScript),
|
|
$"Destiny adventure script should exist at {destinyScript}");
|
|
|
|
// Phase 6: Verify all secret branch IDs match the expected count
|
|
Assert.Equal(Adventures.AdventureEngine.TotalSecretBranchThemes, allSecretBranches.Length);
|
|
|
|
// Build report
|
|
var report = new System.Text.StringBuilder();
|
|
report.AppendLine();
|
|
report.AppendLine("╔════════════════════════════════════════════════════════════╗");
|
|
report.AppendLine($"║ ULTIMATE ENDING REPORT (seed={seed,-6}) ║");
|
|
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
|
|
report.AppendLine($"║ Destiny token obtained: {(gotDestinyToken ? "YES" : "NO"),-34}║");
|
|
report.AppendLine($"║ Destiny adventure unlocked: {(state.UnlockedAdventures.Contains(AdventureTheme.Destiny) ? "YES" : "NO"),-30}║");
|
|
report.AppendLine($"║ Adventures completed: {state.CompletedAdventures.Count,2}/{regularThemes.Count,-33}║");
|
|
report.AppendLine($"║ Secret branches found: {state.CompletedSecretBranches.Count,2}/{Adventures.AdventureEngine.TotalSecretBranchThemes,-32}║");
|
|
report.AppendLine($"║ Ultimate ending achievable: YES{' ',-27}║");
|
|
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
|
|
report.AppendLine("║ Secret Branches: ║");
|
|
foreach (var branch in allSecretBranches)
|
|
{
|
|
bool found = state.CompletedSecretBranches.Contains(branch);
|
|
report.AppendLine($"║ {(found ? "[x]" : "[ ]")} {branch,-52}║");
|
|
}
|
|
report.AppendLine("╚════════════════════════════════════════════════════════════╝");
|
|
|
|
Console.WriteLine(report.ToString());
|
|
}
|
|
|
|
// ── 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)!;
|
|
}
|
|
}
|