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;
+ }
+ }
+}