- Localize box names and rarity in PlaythroughCapture test events - Fix name truncation to account for 2-char prefix (► / ) in inventory table - Add AdventureToken detail panel showing linked adventure name - Show remaining quantity after consuming an item - Localize inventory column headers (Nom, Rareté, Qté in FR)
1704 lines
73 KiB
C#
1704 lines
73 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.Localization;
|
|
using OpenTheBox.Rendering;
|
|
using OpenTheBox.Rendering.Panels;
|
|
using OpenTheBox.Simulation;
|
|
using OpenTheBox.Simulation.Actions;
|
|
using OpenTheBox.Simulation.Events;
|
|
using Spectre.Console;
|
|
using Spectre.Console.Rendering;
|
|
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")]
|
|
[InlineData("destiny")]
|
|
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")]
|
|
[InlineData("destiny")]
|
|
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);
|
|
}
|
|
|
|
// ── French translation tag coverage ────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("space")]
|
|
[InlineData("medieval")]
|
|
[InlineData("pirate")]
|
|
[InlineData("contemporary")]
|
|
[InlineData("sentimental")]
|
|
[InlineData("prehistoric")]
|
|
[InlineData("cosmic")]
|
|
[InlineData("microscopic")]
|
|
[InlineData("darkfantasy")]
|
|
[InlineData("destiny")]
|
|
public void Adventure_FrenchTranslationCoversAllTags(string theme)
|
|
{
|
|
var enPath = Path.Combine(ContentRoot, "adventures", theme, "intro.lor");
|
|
var frPath = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor");
|
|
Assert.True(File.Exists(enPath), $"Missing EN script: {enPath}");
|
|
Assert.True(File.Exists(frPath), $"Missing FR translation: {frPath}");
|
|
|
|
// Extract tags from EN file: tags appear at end of lines as #tag-name
|
|
var enContent = File.ReadAllLines(enPath);
|
|
var tagRegex = new System.Text.RegularExpressions.Regex(@"#([a-z][a-z0-9_-]*)\b");
|
|
var enTags = new HashSet<string>();
|
|
foreach (var line in enContent)
|
|
{
|
|
var trimmed = line.TrimStart();
|
|
if (trimmed.StartsWith("//")) continue;
|
|
|
|
foreach (System.Text.RegularExpressions.Match m in tagRegex.Matches(line))
|
|
{
|
|
enTags.Add(m.Groups[1].Value);
|
|
}
|
|
}
|
|
|
|
// Extract tags from FR file: tags appear at start of lines as #tag-name
|
|
var frContent = File.ReadAllLines(frPath);
|
|
var frTags = new HashSet<string>();
|
|
foreach (var line in frContent)
|
|
{
|
|
var trimmed = line.TrimStart();
|
|
if (trimmed.StartsWith("#"))
|
|
{
|
|
var match = tagRegex.Match(trimmed);
|
|
if (match.Success)
|
|
frTags.Add(match.Groups[1].Value);
|
|
}
|
|
}
|
|
|
|
var missingInFr = enTags.Except(frTags).ToList();
|
|
Assert.True(
|
|
missingInFr.Count == 0,
|
|
$"[{theme}] {missingInFr.Count} EN tag(s) missing in FR translation:\n " +
|
|
string.Join("\n ", missingInFr.OrderBy(t => t)));
|
|
}
|
|
|
|
// ── 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();
|
|
|
|
// 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 allCrafted = expectedCraftedItems.IsSubsetOf(seenDefinitionIds);
|
|
|
|
if (allUIFeatures && allCosmetics && allAdventures && allResources
|
|
&& allLore && allStats && 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 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());
|
|
}
|
|
|
|
// ── Save Snapshot Generator ────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Generates save files at key progression milestones for visual testing.
|
|
/// Snapshots are saved to saves/ as snapshot_1.otb through snapshot_9.otb.
|
|
/// Load them in-game with Ctrl+1..9 for instant visual testing.
|
|
///
|
|
/// Run with: dotnet test --filter "GenerateSaveSnapshots" --logger "console;verbosity=detailed"
|
|
/// </summary>
|
|
[Fact]
|
|
public void GenerateSaveSnapshots()
|
|
{
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
|
|
var simulation = new GameSimulation(registry, new Random(42));
|
|
var craftingEngine = new CraftingEngine();
|
|
var state = GameState.Create("SnapshotPlayer", Locale.FR);
|
|
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
// Define snapshot points: (box count threshold, slot name, description)
|
|
var snapshotDefs = new (int boxes, string slot, string desc)[]
|
|
{
|
|
(10, "snapshot_1", "Very early game"),
|
|
(30, "snapshot_2", "First meta unlocks"),
|
|
(75, "snapshot_3", "Several UI panels"),
|
|
(150, "snapshot_4", "Adventures + cosmetics"),
|
|
(300, "snapshot_5", "Crafting + workshops"),
|
|
(500, "snapshot_6", "Most features"),
|
|
(750, "snapshot_7", "Near completion"),
|
|
(1000, "snapshot_8", "Endgame"),
|
|
(2000, "snapshot_9", "Post-endgame"),
|
|
};
|
|
|
|
int nextSnapshotIdx = 0;
|
|
int totalBoxesOpened = 0;
|
|
int maxTarget = snapshotDefs[^1].boxes;
|
|
|
|
var saveManager = new Persistence.SaveManager();
|
|
var report = new System.Text.StringBuilder();
|
|
report.AppendLine();
|
|
report.AppendLine("╔════════════════════════════════════════════════════════════╗");
|
|
report.AppendLine("║ SAVE SNAPSHOT GENERATOR ║");
|
|
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
|
|
|
|
for (int i = 0; i < 15_000 && totalBoxesOpened < maxTarget; 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 };
|
|
simulation.ProcessAction(action, state);
|
|
|
|
// 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);
|
|
|
|
totalBoxesOpened++;
|
|
|
|
// Check if we've hit a snapshot point
|
|
if (nextSnapshotIdx < snapshotDefs.Length &&
|
|
totalBoxesOpened >= snapshotDefs[nextSnapshotIdx].boxes)
|
|
{
|
|
var (_, slot, desc) = snapshotDefs[nextSnapshotIdx];
|
|
state.TotalBoxesOpened = totalBoxesOpened;
|
|
saveManager.Save(state, slot);
|
|
|
|
int uiCount = state.UnlockedUIFeatures.Count;
|
|
int cosCount = state.UnlockedCosmetics.Count;
|
|
int advCount = state.UnlockedAdventures.Count;
|
|
int wsCount = state.UnlockedWorkstations.Count;
|
|
int invCount = state.Inventory.GroupBy(x => x.DefinitionId).Count();
|
|
|
|
report.AppendLine($"║ Ctrl+{nextSnapshotIdx + 1}: {slot,-14} box #{totalBoxesOpened,-5}" +
|
|
$" UI:{uiCount,2} Cos:{cosCount,2} Adv:{advCount,2} WS:{wsCount,2} Inv:{invCount,3} ║");
|
|
report.AppendLine($"║ {desc,-56}║");
|
|
|
|
nextSnapshotIdx++;
|
|
}
|
|
}
|
|
|
|
report.AppendLine("╠════════════════════════════════════════════════════════════╣");
|
|
report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-38}║");
|
|
report.AppendLine($"║ Snapshots generated: {nextSnapshotIdx,-37}║");
|
|
report.AppendLine("╚════════════════════════════════════════════════════════════╝");
|
|
|
|
Console.WriteLine(report.ToString());
|
|
|
|
Assert.True(nextSnapshotIdx == snapshotDefs.Length,
|
|
$"Expected {snapshotDefs.Length} snapshots but only generated {nextSnapshotIdx}. " +
|
|
$"Game ran out of boxes at {totalBoxesOpened}.");
|
|
}
|
|
|
|
// ── Playthrough Capture ─────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Simulates a game playthrough and captures the rendered output at each step.
|
|
/// Outputs a detailed report with the action taken and full panel rendering.
|
|
/// Run with: dotnet test --filter "PlaythroughCapture" --logger "console;verbosity=detailed"
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(42, 15)]
|
|
[InlineData(777, 15)]
|
|
public void PlaythroughCapture(int seed, int steps)
|
|
{
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
|
|
var simulation = new GameSimulation(registry, new Random(seed));
|
|
var craftingEngine = new CraftingEngine();
|
|
var loc = new LocalizationManager(Locale.FR);
|
|
var state = GameState.Create("TestPlayer", Locale.FR);
|
|
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
var report = new System.Text.StringBuilder();
|
|
report.AppendLine();
|
|
report.AppendLine($"╔═══════════════════════════════════════════════════════════════╗");
|
|
report.AppendLine($"║ PLAYTHROUGH CAPTURE — seed={seed}, {steps} steps ║");
|
|
report.AppendLine($"╚═══════════════════════════════════════════════════════════════╝");
|
|
|
|
for (int step = 1; step <= steps; step++)
|
|
{
|
|
// ── Render current game state panels ──
|
|
var context = RenderContext.FromGameState(state);
|
|
string panelOutput = RenderGameStatePanels(state, context, registry, loc);
|
|
|
|
report.AppendLine();
|
|
report.AppendLine($"┌─── Step {step} ─── Boxes: {state.TotalBoxesOpened} | UI: {state.UnlockedUIFeatures.Count} | Inv: {state.Inventory.Count} ───");
|
|
report.AppendLine(panelOutput);
|
|
|
|
// ── Find a box to open ──
|
|
var box = state.Inventory.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
|
|
if (box is null)
|
|
{
|
|
report.AppendLine("│ ACTION: No boxes left — stopping.");
|
|
report.AppendLine("└───────────────────────────────────────");
|
|
break;
|
|
}
|
|
|
|
var boxDef = registry.GetBox(box.DefinitionId);
|
|
string boxName = boxDef is not null ? loc.Get(boxDef.NameKey) : box.DefinitionId;
|
|
report.AppendLine($"│ ACTION: Open box \"{boxName}\"");
|
|
|
|
// ── Open the box ──
|
|
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
|
|
var events = simulation.ProcessAction(action, state);
|
|
|
|
// ── Log events ──
|
|
foreach (var evt in events)
|
|
{
|
|
switch (evt)
|
|
{
|
|
case BoxOpenedEvent boe:
|
|
var bd = registry.GetBox(boe.BoxId);
|
|
string bn = bd is not null ? loc.Get(bd.NameKey) : boe.BoxId;
|
|
if (!boe.IsAutoOpen)
|
|
report.AppendLine($"│ EVENT: BoxOpened \"{bn}\"");
|
|
break;
|
|
case ItemReceivedEvent ire:
|
|
var idef = registry.GetItem(ire.Item.DefinitionId);
|
|
var iboxDef = idef is null ? registry.GetBox(ire.Item.DefinitionId) : null;
|
|
string iname = idef is not null
|
|
? loc.Get(idef.NameKey)
|
|
: iboxDef is not null
|
|
? loc.Get(iboxDef.NameKey)
|
|
: ire.Item.DefinitionId;
|
|
var rarityEnum = idef?.Rarity ?? iboxDef?.Rarity ?? ItemRarity.Common;
|
|
string rarity = loc.Get($"rarity.{rarityEnum.ToString().ToLower()}");
|
|
report.AppendLine($"│ EVENT: Received [{rarity}] \"{iname}\"");
|
|
break;
|
|
case UIFeatureUnlockedEvent ufe:
|
|
context.Unlock(ufe.Feature);
|
|
report.AppendLine($"│ EVENT: ★ UI Feature unlocked: {ufe.Feature}");
|
|
break;
|
|
case AdventureUnlockedEvent aue:
|
|
report.AppendLine($"│ EVENT: Adventure unlocked: {aue.Theme}");
|
|
break;
|
|
case ResourceChangedEvent rce:
|
|
report.AppendLine($"│ EVENT: Resource {rce.Type}: {rce.OldValue} → {rce.NewValue}");
|
|
break;
|
|
case CraftingStartedEvent cse:
|
|
report.AppendLine($"│ EVENT: Crafting started: {cse.RecipeId} at {cse.Workstation}");
|
|
break;
|
|
case InteractionTriggeredEvent ite:
|
|
report.AppendLine($"│ EVENT: Interaction: {ite.DescriptionKey}");
|
|
break;
|
|
case MessageEvent me:
|
|
report.AppendLine($"│ EVENT: Message: {me.MessageKey}");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ── 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);
|
|
var coll = craftingEngine.CollectCompleted(state, registry);
|
|
foreach (var ce in coll.OfType<ItemReceivedEvent>())
|
|
{
|
|
var cd = registry.GetItem(ce.Item.DefinitionId);
|
|
string cn = cd is not null ? loc.Get(cd.NameKey) : ce.Item.DefinitionId;
|
|
report.AppendLine($"│ CRAFT: Collected \"{cn}\"");
|
|
}
|
|
var cascade = craftingEngine.AutoCraftCheck(state, registry);
|
|
keepCrafting = cascade.OfType<CraftingStartedEvent>().Any();
|
|
} while (keepCrafting);
|
|
|
|
state.TotalBoxesOpened++;
|
|
report.AppendLine("└───────────────────────────────────────");
|
|
}
|
|
|
|
// ── Final state rendering ──
|
|
report.AppendLine();
|
|
report.AppendLine("╔═══════════════════════════════════════════════════════════════╗");
|
|
report.AppendLine($"║ FINAL STATE — {state.TotalBoxesOpened} boxes opened ║");
|
|
report.AppendLine("╚═══════════════════════════════════════════════════════════════╝");
|
|
var finalCtx = RenderContext.FromGameState(state);
|
|
report.AppendLine(RenderGameStatePanels(state, finalCtx, registry, loc));
|
|
|
|
Console.WriteLine(report.ToString());
|
|
Assert.True(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders game state panels to a plain string (strips ANSI for readability).
|
|
/// </summary>
|
|
private static string RenderGameStatePanels(
|
|
GameState state, RenderContext ctx,
|
|
ContentRegistry registry, LocalizationManager loc)
|
|
{
|
|
var sb = new System.Text.StringBuilder();
|
|
var writer = new StringWriter();
|
|
var console = AnsiConsole.Create(new AnsiConsoleSettings
|
|
{
|
|
Out = new AnsiConsoleOutput(writer),
|
|
Ansi = AnsiSupport.No, // plain text, no ANSI escapes
|
|
ColorSystem = ColorSystemSupport.NoColors
|
|
});
|
|
console.Profile.Width = SpectreRenderer.RefWidth;
|
|
|
|
// Portrait
|
|
if (ctx.HasPortraitPanel)
|
|
{
|
|
console.Write(PortraitPanel.Render(state.Appearance));
|
|
}
|
|
|
|
// Stats
|
|
if (ctx.HasStatsPanel)
|
|
{
|
|
console.Write(StatsPanel.Render(state, loc));
|
|
}
|
|
|
|
// Resources
|
|
if (ctx.HasResourcePanel)
|
|
{
|
|
console.Write(ResourcePanel.Render(state));
|
|
}
|
|
|
|
// Inventory (compact)
|
|
if (ctx.HasInventoryPanel)
|
|
{
|
|
console.Write(InventoryPanel.Render(state, registry, loc, compact: true));
|
|
}
|
|
|
|
// Crafting
|
|
if (ctx.HasCraftingPanel)
|
|
{
|
|
console.Write(CraftingPanel.Render(state, registry, loc));
|
|
}
|
|
|
|
string output = writer.ToString();
|
|
if (!string.IsNullOrWhiteSpace(output))
|
|
{
|
|
foreach (var line in output.Split('\n'))
|
|
sb.AppendLine($"│ {line.TrimEnd()}");
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine("│ (no panels unlocked yet)");
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
// ── Inventory Render Capture ────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Captures the interactive inventory rendering at multiple progression stages.
|
|
/// Shows the inventory table with selection highlight and detail panels
|
|
/// for each item type (consumable, lore, cosmetic, material).
|
|
/// Run with: dotnet test --filter "InventoryRenderCapture" --logger "console;verbosity=detailed"
|
|
/// </summary>
|
|
[Fact]
|
|
public void InventoryRenderCapture()
|
|
{
|
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
|
|
var simulation = new GameSimulation(registry, new Random(42));
|
|
var craftingEngine = new CraftingEngine();
|
|
var loc = new LocalizationManager(Locale.FR);
|
|
var state = GameState.Create("InvPlayer", Locale.FR);
|
|
|
|
var starterBox = ItemInstance.Create("box_starter");
|
|
state.AddItem(starterBox);
|
|
|
|
var report = new System.Text.StringBuilder();
|
|
report.AppendLine();
|
|
report.AppendLine("╔═══════════════════════════════════════════════════════════════════╗");
|
|
report.AppendLine("║ INVENTORY RENDER CAPTURE — seed=42 ║");
|
|
report.AppendLine("╚═══════════════════════════════════════════════════════════════════╝");
|
|
|
|
// Snapshot points for inventory renders
|
|
var capturePoints = new[] { 20, 50, 100, 200, 500 };
|
|
int nextCapture = 0;
|
|
|
|
var writer = new StringWriter();
|
|
var console = AnsiConsole.Create(new AnsiConsoleSettings
|
|
{
|
|
Out = new AnsiConsoleOutput(writer),
|
|
Ansi = AnsiSupport.No,
|
|
ColorSystem = ColorSystemSupport.NoColors
|
|
});
|
|
console.Profile.Width = SpectreRenderer.RefWidth;
|
|
|
|
for (int i = 0; i < 5000 && state.TotalBoxesOpened < 500; 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 };
|
|
simulation.ProcessAction(action, state);
|
|
|
|
// 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);
|
|
|
|
state.TotalBoxesOpened++;
|
|
|
|
if (nextCapture < capturePoints.Length &&
|
|
state.TotalBoxesOpened >= capturePoints[nextCapture])
|
|
{
|
|
report.AppendLine();
|
|
report.AppendLine($"┌─── Box #{state.TotalBoxesOpened} ── Inventory: {state.Inventory.GroupBy(x => x.DefinitionId).Count()} types ───");
|
|
|
|
var grouped = InventoryPanel.GetGroupedItems(state, registry);
|
|
|
|
// Render inventory with first item selected
|
|
writer.GetStringBuilder().Clear();
|
|
console.Write(InventoryPanel.Render(state, registry, loc, selectedIndex: 0));
|
|
foreach (var line in writer.ToString().Split('\n'))
|
|
report.AppendLine($"│ {line.TrimEnd()}");
|
|
|
|
// Render detail panel for first item
|
|
if (grouped.Count > 0)
|
|
{
|
|
writer.GetStringBuilder().Clear();
|
|
var detail = InventoryPanel.RenderDetailPanel(grouped[0], registry, loc, state);
|
|
if (detail is not null)
|
|
{
|
|
console.Write(detail);
|
|
foreach (var line in writer.ToString().Split('\n'))
|
|
report.AppendLine($"│ {line.TrimEnd()}");
|
|
}
|
|
}
|
|
|
|
// Show detail panel for specific item types if present
|
|
var interestingTypes = new[] { ItemCategory.Box, ItemCategory.Consumable, ItemCategory.LoreFragment, ItemCategory.Cosmetic, ItemCategory.Material };
|
|
foreach (var cat in interestingTypes)
|
|
{
|
|
var sample = grouped.FirstOrDefault(g => g.Category == cat);
|
|
if (sample is not null && (grouped.Count == 0 || sample != grouped[0]))
|
|
{
|
|
int idx = grouped.IndexOf(sample);
|
|
report.AppendLine($"│ ── Detail for [{cat}]: {sample.DefId} ──");
|
|
writer.GetStringBuilder().Clear();
|
|
var detailAlt = InventoryPanel.RenderDetailPanel(sample, registry, loc, state);
|
|
if (detailAlt is not null)
|
|
{
|
|
console.Write(detailAlt);
|
|
foreach (var line in writer.ToString().Split('\n'))
|
|
report.AppendLine($"│ {line.TrimEnd()}");
|
|
}
|
|
}
|
|
}
|
|
|
|
report.AppendLine("└───────────────────────────────────────────────────────────────");
|
|
nextCapture++;
|
|
}
|
|
}
|
|
|
|
Console.WriteLine(report.ToString());
|
|
Assert.True(nextCapture > 0, "No captures were taken — game may not have generated enough boxes.");
|
|
}
|
|
|
|
// ── 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)!;
|
|
}
|
|
}
|