diff --git a/Anvil/Localization/AnvilStrings.Designer.cs b/Anvil/Localization/AnvilStrings.Designer.cs new file mode 100644 index 0000000..aa81f96 --- /dev/null +++ b/Anvil/Localization/AnvilStrings.Designer.cs @@ -0,0 +1,96 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Anvil.Localization +{ + using System; + + /// + /// A strongly-typed resource class for AnvilStrings.resx. + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute( + "System.Resources.Tools.StronglyTypedResourceBuilder", + "16.0.0.0" + )] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class AnvilStrings + { + private static global::System.Resources.ResourceManager resourceMan; + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "Microsoft.Performance", + "CA1811:AvoidUncalledPrivateCode" + )] + internal AnvilStrings() { } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if (object.ReferenceEquals(resourceMan, null)) + { + global::System.Resources.ResourceManager temp = + new global::System.Resources.ResourceManager( + "Anvil.Localization.AnvilStrings", + typeof(AnvilStrings).Assembly + ); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Globalization.CultureInfo Culture + { + get { return resourceCulture; } + set { resourceCulture = value; } + } + + internal static string Condition_Normal => + ResourceManager.GetString("Condition_Normal", resourceCulture); + internal static string Condition_Good => + ResourceManager.GetString("Condition_Good", resourceCulture); + internal static string Condition_Excellent => + ResourceManager.GetString("Condition_Excellent", resourceCulture); + internal static string Condition_Poor => + ResourceManager.GetString("Condition_Poor", resourceCulture); + internal static string Condition_Centered => + ResourceManager.GetString("Condition_Centered", resourceCulture); + internal static string Condition_Sturdy => + ResourceManager.GetString("Condition_Sturdy", resourceCulture); + internal static string Condition_Pliant => + ResourceManager.GetString("Condition_Pliant", resourceCulture); + internal static string Condition_Malleable => + ResourceManager.GetString("Condition_Malleable", resourceCulture); + internal static string Condition_Primed => + ResourceManager.GetString("Condition_Primed", resourceCulture); + internal static string Condition_GoodOmen => + ResourceManager.GetString("Condition_GoodOmen", resourceCulture); + internal static string Condition_Robust => + ResourceManager.GetString("Condition_Robust", resourceCulture); + + internal static string SelfTest_RecipeDataAdapterLoad_Name => + ResourceManager.GetString("SelfTest_RecipeDataAdapterLoad_Name", resourceCulture); + } +} diff --git a/Anvil/Localization/AnvilStrings.de.resx b/Anvil/Localization/AnvilStrings.de.resx new file mode 100644 index 0000000..41926c6 --- /dev/null +++ b/Anvil/Localization/AnvilStrings.de.resx @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Normal + + + Gut + + + Hervorragend + + + Schlecht + + + Zentriert + + + Stabil + + + Geschmeidig + + + Formbar + + + Bereit + + + Gutes Omen + + + Robust + + + + Anvil: RecipeData adapter load + + diff --git a/Anvil/Localization/AnvilStrings.resx b/Anvil/Localization/AnvilStrings.resx new file mode 100644 index 0000000..aab4e27 --- /dev/null +++ b/Anvil/Localization/AnvilStrings.resx @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Normal + + + Good + + + Excellent + + + Poor + + + Centered + + + Sturdy + + + Pliant + + + Malleable + + + Primed + + + Good Omen + + + Robust + + + + Anvil: RecipeData adapter load + + diff --git a/Anvil/RecipeData/Internal/LuminaRecipeAdapter.cs b/Anvil/RecipeData/Internal/LuminaRecipeAdapter.cs new file mode 100644 index 0000000..e5bd358 --- /dev/null +++ b/Anvil/RecipeData/Internal/LuminaRecipeAdapter.cs @@ -0,0 +1,765 @@ +// Adapter that walks the Lumina sheets and turns them into Anvil records. +// This class is the only place in the plugin that touches Dalamud + Lumina +// types. Everything outside Anvil.RecipeData.Internal sees only the BCL +// surface of the AnvilXxx records and the RecipeDataCatalog. +// +// Threading: LoadAsync MUST be dispatched on the framework thread (the +// RecipeDataLoadHostedService does this). IDataManager.GetExcelSheet has +// no documented thread-safety guarantee, and the load is CPU-bound at +// well under a second, so the conservative choice is the safe one. +// +// SH-15 option B (v0.1.0): every AnvilRecipe ships with +// MissionHasMaterialMiracle = false and MissionHasSteadyHand = false. +// The Cosmic detection still flags IsCosmic from Recipe.Number == 0, but +// the WKS mission sheet chain is intentionally not walked here. v0.2.0+ +// will replace the constant-false assignments with the WKS resolution +// without touching anything else in the adapter. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Resources; +using System.Threading; +using System.Threading.Tasks; +using Anvil.RecipeData.Mechanics; +using Dalamud.Plugin.Services; +using Lumina.Excel.Sheets; +using Microsoft.Extensions.Logging; + +namespace Anvil.RecipeData.Internal; + +internal sealed class LuminaRecipeAdapter +{ + // CraftType row 0 = Carpenter; CraftType row 7 = Culinarian. ClassJobId + // is offset by 8 in FFXIV (CRP = 8, CUL = 15). The map is explicit so a + // future schema drift jumps out instead of silently miscounting. + private static readonly IReadOnlyDictionary ClassJobIdByCraftType = new Dictionary< + uint, + uint + > + { + [0] = 8, // Carpenter + [1] = 9, // Blacksmith + [2] = 10, // Armorer + [3] = 11, // Goldsmith + [4] = 12, // Leatherworker + [5] = 13, // Weaver + [6] = 14, // Alchemist + [7] = 15, // Culinarian + }; + + // ItemFood BaseParam ids verified against ffxiv-datamining BaseParam.csv + // (spec §2.6 SPEC-HOLE 9). Anything outside this set is non-crafting. + private static readonly IReadOnlyDictionary CraftStatByBaseParam = + new Dictionary + { + [11] = AnvilFoodStat.Cp, + [70] = AnvilFoodStat.Craftsmanship, + [71] = AnvilFoodStat.Control, + }; + + private readonly IDataManager _data; + private readonly ILogger _logger; + private readonly RecipeDataCatalog _catalog; + private readonly ResourceManager _strings; + + public LuminaRecipeAdapter( + IDataManager data, + ILogger logger, + RecipeDataCatalog catalog + ) + { + _data = data; + _logger = logger; + _catalog = catalog; + _strings = Localization.AnvilStrings.ResourceManager; + } + + // SelfTest-step entry. Returns the same instance the DI container holds + // - the property keeps the contract that "the catalog you injected and + // the one the adapter populates are the same singleton". + internal RecipeDataCatalog Catalog => _catalog; + + // Synchronous Lumina pass wrapped in Task.FromResult so the + // IHostedService entry point looks async. There is no I/O - the bottleneck + // is dictionary construction. + internal Task LoadAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(LoadInternal(ct)); + } + + // Synchronous worker. Public to the HostedService adapter (same assembly) + // for the dispatch-onto-framework-thread call site. + internal RecipeDataLoadResult LoadInternal(CancellationToken ct) + { + var stopwatch = Stopwatch.StartNew(); + var warnings = new List(); + + var buffs = BuildBuffs(warnings); + ct.ThrowIfCancellationRequested(); + + var conditions = BuildConditions(); + ct.ThrowIfCancellationRequested(); + + var (actionsByKind, actionsByRowId) = BuildActions(warnings); + ct.ThrowIfCancellationRequested(); + + var (foods, medicines) = BuildFoods(warnings); + ct.ThrowIfCancellationRequested(); + + var (recipes, recipesByOutputItem, recipesByJob) = BuildRecipes(warnings); + ct.ThrowIfCancellationRequested(); + + var items = BuildItems(recipes, foods, medicines, warnings); + ct.ThrowIfCancellationRequested(); + + var state = new CatalogState + { + IsLoaded = true, + LoadedAt = DateTime.UtcNow, + RecipesById = recipes, + RecipesByOutputItemId = recipesByOutputItem, + RecipesByClassJobId = recipesByJob, + ItemsById = items, + ActionsByKind = actionsByKind, + ActionsByLuminaRowId = actionsByRowId, + BuffsByKind = buffs, + ConditionsByKind = conditions, + Foods = foods, + Medicines = medicines, + }; + + _catalog.ApplyLoad(state); + stopwatch.Stop(); + + _logger.LogInformation( + "Anvil RecipeData loaded in {DurationMs}ms: {RecipeCount} recipes, " + + "{ItemCount} items, {ActionCount} actions, {FoodCount} foods, " + + "{MedicineCount} medicines, {WarningCount} warnings", + stopwatch.ElapsedMilliseconds, + recipes.Count, + items.Count, + actionsByKind.Count, + foods.Count, + medicines.Count, + warnings.Count + ); + + return new RecipeDataLoadResult( + recipes.Count, + items.Count, + actionsByKind.Count, + foods.Count + medicines.Count, + stopwatch.Elapsed, + warnings + ); + } + + // ---------------- Buffs ---------------- + private IReadOnlyDictionary BuildBuffs(List warnings) + { + var statusSheet = _data.GetExcelSheet(); + var result = new Dictionary(BuffMechanicsTable.Entries.Count); + + foreach (var (kind, mechanics) in BuffMechanicsTable.Entries) + { + if (!statusSheet.TryGetRow(mechanics.StatusId, out var statusRow)) + { + warnings.Add( + $"AnvilBuff {kind} -> StatusId {mechanics.StatusId} not in Status sheet; " + + "using mechanics-table values for icon and display name." + ); + result[kind] = new AnvilBuff + { + Kind = kind, + StatusId = mechanics.StatusId, + DisplayName = kind.ToString(), + IconId = mechanics.IconId, + StackMax = mechanics.StackMax, + Behavior = mechanics.Behavior, + DefaultDurationSteps = mechanics.DefaultDurationSteps, + DefaultDurationSeconds = mechanics.DefaultDurationSeconds, + DefaultDurationActions = mechanics.DefaultDurationActions, + Category = mechanics.Category, + }; + continue; + } + + result[kind] = new AnvilBuff + { + Kind = kind, + StatusId = mechanics.StatusId, + DisplayName = statusRow.Name.ExtractText(), + IconId = mechanics.IconId, + StackMax = mechanics.StackMax, + Behavior = mechanics.Behavior, + DefaultDurationSteps = mechanics.DefaultDurationSteps, + DefaultDurationSeconds = mechanics.DefaultDurationSeconds, + DefaultDurationActions = mechanics.DefaultDurationActions, + Category = mechanics.Category, + }; + } + + return result; + } + + // ---------------- Conditions ---------------- + private IReadOnlyDictionary BuildConditions() + { + var result = new Dictionary( + ConditionMechanicsTable.Entries.Count + ); + + foreach (var (kind, mechanics) in ConditionMechanicsTable.Entries) + { + result[kind] = new AnvilCondition + { + Kind = kind, + DisplayName = ResolveConditionDisplayName(kind), + QualityMultiplier = mechanics.QualityMultiplier, + ProgressMultiplier = mechanics.ProgressMultiplier, + CpDiscountMultiplier = mechanics.CpDiscountMultiplier, + DurabilityDiscountMultiplier = mechanics.DurabilityDiscountMultiplier, + BaseProbability = mechanics.BaseProbability, + }; + } + + return result; + } + + private string ResolveConditionDisplayName(AnvilConditionKind kind) + { + var key = $"Condition_{kind}"; + var value = _strings.GetString(key); + return value ?? kind.ToString(); + } + + // ---------------- Actions ---------------- + private ( + IReadOnlyDictionary ByKind, + IReadOnlyDictionary ByRowId + ) BuildActions(List warnings) + { + // Two sheet walks. CraftAction carries the modern crafting actions + // (Touch / Synthesis / Reflect / Heart and Soul / Quick Innovation / + // Trained Perfection). Action carries the seven classic step-counter + // buff actions plus the two Cosmic singletons - and a pile of + // unrelated combat / fishing / gathering rows that the whitelist + // filters out. + var collected = new Dictionary(); + + CollectCraftActionSheet(collected, warnings); + CollectActionSheet(collected, warnings); + + var byKind = new Dictionary(); + var byRowId = new Dictionary(); + + foreach (var (kind, accumulator) in collected) + { + if (!ActionMechanicsTable.Entries.TryGetValue(kind, out var mechanics)) + { + warnings.Add($"AnvilAction {kind} has no mechanics-table entry; skipped."); + continue; + } + + VerifyPerJobConsistency(kind, accumulator, warnings); + + var action = new AnvilAction + { + Kind = kind, + DisplayName = accumulator.DisplayName ?? kind.ToString(), + IconId = accumulator.IconId, + LevelRequired = accumulator.LevelRequired, + CpCost = mechanics.CpCost, + DurabilityCost = mechanics.DurabilityCost, + EfficiencyProgress = mechanics.EfficiencyProgress, + EfficiencyQuality = mechanics.EfficiencyQuality, + Category = mechanics.Category, + GrantsBuff = mechanics.GrantsBuff, + IqStackBonus = mechanics.IqStackBonus, + Flags = mechanics.Flags, + RowIdByClassJob = accumulator.RowIdByClassJob.ToImmutableDictionary(), + SpecialistOnly = accumulator.SpecialistOnly, + ChargesPerCraft = mechanics.ChargesPerCraft, + }; + + byKind[kind] = action; + foreach (var rowId in accumulator.RowIdByClassJob.Values) + byRowId[rowId] = action; + } + + // Mechanics-table entries that no sheet walk produced - usually a + // patch dropped or renamed something. The catalog is still usable; + // consumers just need to know. + foreach (var kind in ActionMechanicsTable.Entries.Keys) + { + if (!byKind.ContainsKey(kind)) + warnings.Add( + $"AnvilAction {kind} is in the mechanics table but never appeared " + + "in the CraftAction / Action sheet walk." + ); + } + + return (byKind, byRowId); + } + + private void CollectCraftActionSheet( + Dictionary collected, + List warnings + ) + { + var sheet = _data.GetExcelSheet(); + foreach (var row in sheet) + { + var name = row.Name.ExtractText(); + if (string.IsNullOrEmpty(name)) + continue; + if (!ActionKindByName.Map.TryGetValue(name, out var kind)) + continue; + + var classJobId = row.ClassJob.RowId; + if (classJobId is < 8 or > 15) + continue; + + if (!collected.TryGetValue(kind, out var acc)) + { + acc = new ActionRowAccumulator(); + collected[kind] = acc; + } + + acc.AddRow( + classJobId, + row.RowId, + name, + row.Icon, + row.ClassJobLevel, + row.Cost, + row.Specialist, + preferCrpIcon: true + ); + } + } + + private void CollectActionSheet( + Dictionary collected, + List warnings + ) + { + var sheet = _data.GetExcelSheet(); + foreach (var row in sheet) + { + var name = row.Name.ExtractText(); + if (string.IsNullOrEmpty(name)) + continue; + if (!ActionKindByName.ActionSheetWhitelist.Contains(name)) + continue; + if (!ActionKindByName.Map.TryGetValue(name, out var kind)) + continue; + + // Legacy ARR singletons (Manipulation 278, Waste Not 279, etc.) + // ship with PrimaryCostValue = 0 and the modern per-job clusters + // ship with the real cost. Filter the dead ones out so they do + // not collide with the active rows. + if (row.PrimaryCostValue == 0) + continue; + + // The Action sheet uses sbyte -1 as the "no specific crafter job" + // sentinel for Cosmic singletons; everything else carries a + // ClassJob RowId between 8 and 15. Cosmic actions normalise to + // sentinel key 0 in RowIdByClassJob. + var rawClassJobId = (int)row.ClassJob.RowId; + uint slot; + if (rawClassJobId is < 0 or > 15) + { + slot = 0; + } + else if (rawClassJobId < 8) + { + // Crafting buff actions on a non-crafter job - probably the + // disambiguator from the whitelist comment. Skip. + continue; + } + else + { + slot = (uint)rawClassJobId; + } + + if (!collected.TryGetValue(kind, out var acc)) + { + acc = new ActionRowAccumulator(); + collected[kind] = acc; + } + + acc.AddRow( + slot, + row.RowId, + name, + row.Icon, + row.ClassJobLevel, + (short)row.PrimaryCostValue, + false, + preferCrpIcon: false + ); + } + } + + private void VerifyPerJobConsistency( + AnvilActionKind kind, + ActionRowAccumulator accumulator, + List warnings + ) + { + // For non-Cosmic actions the eight per-job rows must agree on cost, + // level, and specialist flag. Icon is allowed to differ (spec §2.3 + // field doc) - we already picked the CRP value as the canonical one. + if ( + ActionMechanicsTable.Entries.TryGetValue(kind, out var mechanics) + && mechanics.Flags.HasFlag(AnvilActionFlags.CosmicOnly) + ) + { + return; + } + + if (accumulator.HasInconsistentCost) + warnings.Add($"AnvilAction {kind} has inconsistent Cost across its per-job rows."); + if (accumulator.HasInconsistentLevel) + warnings.Add( + $"AnvilAction {kind} has inconsistent ClassJobLevel across its per-job rows." + ); + if (accumulator.HasInconsistentSpecialist) + warnings.Add( + $"AnvilAction {kind} has inconsistent Specialist flag across its per-job rows." + ); + } + + // ---------------- Foods ---------------- + private (IReadOnlyList Foods, IReadOnlyList Medicines) BuildFoods( + List warnings + ) + { + var itemFoodSheet = _data.GetExcelSheet(); + var itemSheet = _data.GetExcelSheet(); + + var foods = new List(); + var medicines = new List(); + + // Walk every Item, follow its ItemAction RowRef, and pick the rows + // that look like crafting food / medicine. The discriminator is the + // first slot of the ItemAction Data array: 48 = "well fed", 49 = + // "medicated". Data[1] carries the ItemFood RowId; Data[2] (and + // DataHQ[2]) carry the duration in seconds. ItemUICategory tells + // Food (46) from Medicine (44) when the Data[0] code is identical + // for both - we prefer the Data[0] split because it is the simpler + // game-side signal. + foreach (var item in itemSheet) + { + if (!item.ItemAction.IsValid) + continue; + var itemAction = item.ItemAction.Value; + var statusCode = itemAction.Data[0]; + if (statusCode is not (48 or 49)) + continue; + + var itemFoodRowId = itemAction.Data[1]; + if (itemFoodRowId == 0) + continue; + if (!itemFoodSheet.TryGetRow(itemFoodRowId, out var itemFood)) + continue; + + var bonuses = ResolveFoodBonuses(itemFood); + if (bonuses.Count == 0) + continue; + + var anvilFood = new AnvilFood + { + ItemId = item.RowId, + DisplayName = item.Name.ExtractText(), + IconId = item.Icon, + DurationSeconds = (short)itemAction.Data[2], + Kind = statusCode == 48 ? AnvilFoodKind.Food : AnvilFoodKind.Medicine, + Bonuses = bonuses, + HasHqVariant = item.CanBeHq, + }; + + if (anvilFood.Kind == AnvilFoodKind.Food) + foods.Add(anvilFood); + else + medicines.Add(anvilFood); + } + + return (foods, medicines); + } + + private IReadOnlyList ResolveFoodBonuses(ItemFood itemFood) + { + var bonuses = new List(); + foreach (var param in itemFood.Params) + { + var baseParamId = param.BaseParam.RowId; + if (!CraftStatByBaseParam.TryGetValue(baseParamId, out var stat)) + continue; + + bonuses.Add( + new AnvilFoodBonus + { + Stat = stat, + Value = param.Value, + ValueHq = param.ValueHQ, + Max = param.Max, + MaxHq = param.MaxHQ, + IsRelative = param.IsRelative, + } + ); + } + return bonuses; + } + + // ---------------- Recipes ---------------- + private ( + IReadOnlyDictionary RecipesById, + IReadOnlyDictionary> RecipesByOutputItem, + IReadOnlyDictionary> RecipesByJob + ) BuildRecipes(List warnings) + { + var recipeSheet = _data.GetExcelSheet(); + var itemSheet = _data.GetExcelSheet(); + + var recipes = new Dictionary(); + var byOutputItem = new Dictionary>(); + var byJob = new Dictionary>(); + + foreach (var recipe in recipeSheet) + { + // Empty placeholder rows have ItemResult = 0. Skip silently. + var outputItemId = recipe.ItemResult.RowId; + if (outputItemId == 0) + continue; + + var levelTable = recipe.RecipeLevelTable.Value; + var craftType = recipe.CraftType.RowId; + if (!ClassJobIdByCraftType.TryGetValue(craftType, out var classJobId)) + { + warnings.Add($"Recipe {recipe.RowId} has unknown CraftType {craftType}; skipped."); + continue; + } + + var difficulty = (int)(levelTable.Difficulty * recipe.DifficultyFactor / 100); + var qualityMax = (int)(levelTable.Quality * recipe.QualityFactor / 100); + var durability = (byte)(levelTable.Durability * recipe.DurabilityFactor / 100); + + // Cosmic detection: Recipe.Number == 0 is the in-game marker for + // a Cosmic Exploration mission recipe. v0.1.0 (SH-15 option B) + // surfaces the flag but does not resolve the WKS mission chain + // for MissionHasMaterialMiracle / MissionHasSteadyHand. + var isCosmic = recipe.Number == 0; + + var ingredients = ResolveIngredients(recipe); + var displayName = itemSheet.TryGetRow(outputItemId, out var outputItem) + ? outputItem.Name.ExtractText() + : string.Empty; + + var anvilRecipe = new AnvilRecipe + { + RecipeId = recipe.RowId, + OutputItemId = outputItemId, + OutputAmount = recipe.AmountResult, + ClassJobId = classJobId, + RecipeLevel = (ushort)recipe.RecipeLevelTable.RowId, + Difficulty = difficulty, + QualityMax = qualityMax, + Durability = durability, + RequiredCraftsmanship = recipe.RequiredCraftsmanship, + RequiredControl = recipe.RequiredControl, + QualityForHQ = recipe.CanHq ? qualityMax : 0, + IsExpertRecipe = recipe.IsExpert, + CanHQ = recipe.CanHq, + IsCosmic = isCosmic, + IsSplendorCosmic = false, + MissionHasMaterialMiracle = false, + MissionHasSteadyHand = false, + IsIshgardExpert = false, + Stars = levelTable.Stars, + ProgressDivider = levelTable.ProgressDivider, + ProgressModifier = levelTable.ProgressModifier, + QualityDivider = levelTable.QualityDivider, + QualityModifier = levelTable.QualityModifier, + Ingredients = ingredients, + DisplayName = displayName, + }; + + ValidateCosmicInvariant(anvilRecipe); + + recipes[recipe.RowId] = anvilRecipe; + + if (!byOutputItem.TryGetValue(outputItemId, out var outBucket)) + { + outBucket = new List(); + byOutputItem[outputItemId] = outBucket; + } + outBucket.Add(anvilRecipe); + + if (!byJob.TryGetValue(classJobId, out var jobBucket)) + { + jobBucket = new List(); + byJob[classJobId] = jobBucket; + } + jobBucket.Add(anvilRecipe); + } + + return ( + recipes, + byOutputItem.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value), + byJob.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value) + ); + } + + private static IReadOnlyList ResolveIngredients(Recipe recipe) + { + var list = new List(); + for (var i = 0; i < recipe.Ingredient.Count; i++) + { + var itemRowId = recipe.Ingredient[i].RowId; + var amount = recipe.AmountIngredient[i]; + if (itemRowId == 0 || amount == 0) + continue; + list.Add(new AnvilRecipeIngredient(itemRowId, amount)); + } + return list; + } + + // Sub-flag implies master flag. SH-15 option B keeps the MissionHas* flags + // at false in v0.1.0 so this throw never fires here, but the check stays + // active so v0.2.0+ catches an inconsistent WKS resolver at catalog-build + // time instead of producing a corrupt simulator state at runtime. + private static void ValidateCosmicInvariant(AnvilRecipe recipe) + { + if (recipe.IsSplendorCosmic && !recipe.IsCosmic) + throw new InvalidOperationException( + $"AnvilRecipe {recipe.RecipeId}: IsSplendorCosmic implies IsCosmic, " + + "but IsCosmic is false." + ); + if (recipe.MissionHasMaterialMiracle && !recipe.IsCosmic) + throw new InvalidOperationException( + $"AnvilRecipe {recipe.RecipeId}: MissionHasMaterialMiracle implies IsCosmic, " + + "but IsCosmic is false." + ); + if (recipe.MissionHasSteadyHand && !recipe.IsCosmic) + throw new InvalidOperationException( + $"AnvilRecipe {recipe.RecipeId}: MissionHasSteadyHand implies IsCosmic, " + + "but IsCosmic is false." + ); + } + + // ---------------- Items ---------------- + private IReadOnlyDictionary BuildItems( + IReadOnlyDictionary recipes, + IReadOnlyList foods, + IReadOnlyList medicines, + List warnings + ) + { + var itemSheet = _data.GetExcelSheet(); + var craftableIds = new HashSet(recipes.Values.Select(r => r.OutputItemId)); + + var wanted = new HashSet(); + foreach (var recipe in recipes.Values) + { + wanted.Add(recipe.OutputItemId); + foreach (var ingredient in recipe.Ingredients) + wanted.Add(ingredient.ItemId); + } + foreach (var food in foods) + wanted.Add(food.ItemId); + foreach (var medicine in medicines) + wanted.Add(medicine.ItemId); + + var result = new Dictionary(wanted.Count); + foreach (var itemId in wanted) + { + if (!itemSheet.TryGetRow(itemId, out var row)) + { + warnings.Add( + $"ItemId {itemId} referenced by a recipe/food is not in the Item sheet." + ); + continue; + } + + result[itemId] = new AnvilItem + { + ItemId = itemId, + DisplayName = row.Name.ExtractText(), + IconId = row.Icon, + ItemLevel = (byte)row.LevelItem.RowId, + CanBeHq = row.CanBeHq, + IsCollectable = row.AlwaysCollectable, + IsCraftable = craftableIds.Contains(itemId), + }; + } + + return result; + } + + // Mutable per-action accumulator used during the two sheet walks. Tracks + // the per-job RowIds, the canonical (CRP-preferred) display name + icon, + // and the cross-row consistency state. + private sealed class ActionRowAccumulator + { + public Dictionary RowIdByClassJob { get; } = new(); + public string? DisplayName { get; private set; } + public uint IconId { get; private set; } + public byte LevelRequired { get; private set; } + public short CpCostSeen { get; private set; } + public bool SpecialistOnly { get; private set; } + + public bool HasInconsistentCost { get; private set; } + public bool HasInconsistentLevel { get; private set; } + public bool HasInconsistentSpecialist { get; private set; } + + private bool _hasFirstRow; + private bool _hasCrpIcon; + + public void AddRow( + uint classJobId, + uint rowId, + string name, + uint icon, + byte classJobLevel, + short cost, + bool specialist, + bool preferCrpIcon + ) + { + RowIdByClassJob[classJobId] = rowId; + + if (!_hasFirstRow) + { + DisplayName = name; + IconId = icon; + LevelRequired = classJobLevel; + CpCostSeen = cost; + SpecialistOnly = specialist; + _hasFirstRow = true; + _hasCrpIcon = preferCrpIcon && classJobId == 8; + return; + } + + // CRP variant (ClassJobId 8) wins the icon competition - spec §2.3 + // mandates the CRP-derived icon as canonical for CraftAction rows. + if (preferCrpIcon && classJobId == 8 && !_hasCrpIcon) + { + IconId = icon; + _hasCrpIcon = true; + } + + if (cost != CpCostSeen) + HasInconsistentCost = true; + if (classJobLevel != LevelRequired) + HasInconsistentLevel = true; + if (specialist != SpecialistOnly) + HasInconsistentSpecialist = true; + } + } +}