feat(recipedata): add LuminaRecipeAdapter + AnvilStrings resx

The adapter is the only place in the plugin that touches Lumina and
Dalamud types - everything outside Anvil.RecipeData.Internal stays on
the BCL surface (Critical Boundary per 00-Anvil-Scope §3.3).

- LuminaRecipeAdapter: walks Status, CraftAction, Action, Item, ItemFood,
  and Recipe + RecipeLevelTable, builds the AnvilXxx records, and
  swaps the CatalogState into RecipeDataCatalog in one atomic write.
  Cosmic actions normalise the Action-sheet ClassJob=-1 sentinel to
  RowIdByClassJob key 0 (spec §2.3.1); the legacy ARR singletons
  (Manipulation 278, Waste Not 279, Innovation 284, Waste Not II 285)
  drop out via the PrimaryCostValue == 0 filter (spec §3.1 #3). The
  CraftAction sheet wins the canonical icon at the CRP variant
  (ClassJobId 8). Per-job rows are cross-checked for Cost, ClassJobLevel,
  and Specialist consistency; mismatches log a warning.
- SH-15 option B is hard-wired: every AnvilRecipe ships with
  MissionHasMaterialMiracle = false, MissionHasSteadyHand = false, and
  IsSplendorCosmic = false in v0.1.0. IsCosmic is still set from
  Recipe.Number == 0 so the catalog can label Cosmic recipes; the WKS
  mission sheet chain is not walked. v0.2.0 will replace those constant
  assignments with the resolver and the rest of the adapter stays put.
- The adapter invariant (Cosmic sub-flag implies the master IsCosmic
  flag) throws on inconsistent recipe states at catalog-build time so
  v0.2.0+ catches a broken WKS resolver before it corrupts the simulator.
- ItemFood walk uses ItemAction.Data[0] (48 = "well fed", 49 =
  "medicated") instead of an ItemAction.Type column that does not exist
  in the current Lumina schema. The bonus extractor filters
  ItemFood.Params to the three crafting BaseParam ids (CP=11,
  Craftsmanship=70, Control=71). Item rows are populated only for the
  ItemIds that recipes / foods actually reference - no 50k-row mirror.
- AnvilStrings.resx + AnvilStrings.de.resx + AnvilStrings.Designer.cs:
  Localization layer for condition display names and the SelfTest step
  name. The adapter looks the condition strings up through the generated
  ResourceManager so culture-switching is a no-restart change later on.
This commit is contained in:
2026-05-27 21:27:55 +02:00
parent eb5753eea6
commit 401ebc9495
4 changed files with 1031 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace Anvil.Localization
{
using System;
/// <summary>
/// A strongly-typed resource class for AnvilStrings.resx.
/// </summary>
[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() { }
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[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;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[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);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Condition_Normal" xml:space="preserve">
<value>Normal</value>
</data>
<data name="Condition_Good" xml:space="preserve">
<value>Gut</value>
</data>
<data name="Condition_Excellent" xml:space="preserve">
<value>Hervorragend</value>
</data>
<data name="Condition_Poor" xml:space="preserve">
<value>Schlecht</value>
</data>
<data name="Condition_Centered" xml:space="preserve">
<value>Zentriert</value>
</data>
<data name="Condition_Sturdy" xml:space="preserve">
<value>Stabil</value>
</data>
<data name="Condition_Pliant" xml:space="preserve">
<value>Geschmeidig</value>
</data>
<data name="Condition_Malleable" xml:space="preserve">
<value>Formbar</value>
</data>
<data name="Condition_Primed" xml:space="preserve">
<value>Bereit</value>
</data>
<data name="Condition_GoodOmen" xml:space="preserve">
<value>Gutes Omen</value>
</data>
<data name="Condition_Robust" xml:space="preserve">
<value>Robust</value>
</data>
<data name="SelfTest_RecipeDataAdapterLoad_Name" xml:space="preserve">
<value>Anvil: RecipeData adapter load</value>
</data>
</root>
+85
View File
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Condition_Normal" xml:space="preserve">
<value>Normal</value>
</data>
<data name="Condition_Good" xml:space="preserve">
<value>Good</value>
</data>
<data name="Condition_Excellent" xml:space="preserve">
<value>Excellent</value>
</data>
<data name="Condition_Poor" xml:space="preserve">
<value>Poor</value>
</data>
<data name="Condition_Centered" xml:space="preserve">
<value>Centered</value>
</data>
<data name="Condition_Sturdy" xml:space="preserve">
<value>Sturdy</value>
</data>
<data name="Condition_Pliant" xml:space="preserve">
<value>Pliant</value>
</data>
<data name="Condition_Malleable" xml:space="preserve">
<value>Malleable</value>
</data>
<data name="Condition_Primed" xml:space="preserve">
<value>Primed</value>
</data>
<data name="Condition_GoodOmen" xml:space="preserve">
<value>Good Omen</value>
</data>
<data name="Condition_Robust" xml:space="preserve">
<value>Robust</value>
</data>
<data name="SelfTest_RecipeDataAdapterLoad_Name" xml:space="preserve">
<value>Anvil: RecipeData adapter load</value>
</data>
</root>
@@ -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<uint, uint> 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<uint, AnvilFoodStat> CraftStatByBaseParam =
new Dictionary<uint, AnvilFoodStat>
{
[11] = AnvilFoodStat.Cp,
[70] = AnvilFoodStat.Craftsmanship,
[71] = AnvilFoodStat.Control,
};
private readonly IDataManager _data;
private readonly ILogger<LuminaRecipeAdapter> _logger;
private readonly RecipeDataCatalog _catalog;
private readonly ResourceManager _strings;
public LuminaRecipeAdapter(
IDataManager data,
ILogger<LuminaRecipeAdapter> 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<RecipeDataLoadResult> 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<string>();
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<AnvilBuffKind, AnvilBuff> BuildBuffs(List<string> warnings)
{
var statusSheet = _data.GetExcelSheet<Status>();
var result = new Dictionary<AnvilBuffKind, AnvilBuff>(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<AnvilConditionKind, AnvilCondition> BuildConditions()
{
var result = new Dictionary<AnvilConditionKind, AnvilCondition>(
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<AnvilActionKind, AnvilAction> ByKind,
IReadOnlyDictionary<uint, AnvilAction> ByRowId
) BuildActions(List<string> 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<AnvilActionKind, ActionRowAccumulator>();
CollectCraftActionSheet(collected, warnings);
CollectActionSheet(collected, warnings);
var byKind = new Dictionary<AnvilActionKind, AnvilAction>();
var byRowId = new Dictionary<uint, AnvilAction>();
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<AnvilActionKind, ActionRowAccumulator> collected,
List<string> warnings
)
{
var sheet = _data.GetExcelSheet<CraftAction>();
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<AnvilActionKind, ActionRowAccumulator> collected,
List<string> warnings
)
{
var sheet = _data.GetExcelSheet<Lumina.Excel.Sheets.Action>();
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<string> 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<AnvilFood> Foods, IReadOnlyList<AnvilFood> Medicines) BuildFoods(
List<string> warnings
)
{
var itemFoodSheet = _data.GetExcelSheet<ItemFood>();
var itemSheet = _data.GetExcelSheet<Item>();
var foods = new List<AnvilFood>();
var medicines = new List<AnvilFood>();
// 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<AnvilFoodBonus> ResolveFoodBonuses(ItemFood itemFood)
{
var bonuses = new List<AnvilFoodBonus>();
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<uint, AnvilRecipe> RecipesById,
IReadOnlyDictionary<uint, IReadOnlyList<AnvilRecipe>> RecipesByOutputItem,
IReadOnlyDictionary<uint, IReadOnlyList<AnvilRecipe>> RecipesByJob
) BuildRecipes(List<string> warnings)
{
var recipeSheet = _data.GetExcelSheet<Recipe>();
var itemSheet = _data.GetExcelSheet<Item>();
var recipes = new Dictionary<uint, AnvilRecipe>();
var byOutputItem = new Dictionary<uint, List<AnvilRecipe>>();
var byJob = new Dictionary<uint, List<AnvilRecipe>>();
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<AnvilRecipe>();
byOutputItem[outputItemId] = outBucket;
}
outBucket.Add(anvilRecipe);
if (!byJob.TryGetValue(classJobId, out var jobBucket))
{
jobBucket = new List<AnvilRecipe>();
byJob[classJobId] = jobBucket;
}
jobBucket.Add(anvilRecipe);
}
return (
recipes,
byOutputItem.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<AnvilRecipe>)kv.Value),
byJob.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<AnvilRecipe>)kv.Value)
);
}
private static IReadOnlyList<AnvilRecipeIngredient> ResolveIngredients(Recipe recipe)
{
var list = new List<AnvilRecipeIngredient>();
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<uint, AnvilItem> BuildItems(
IReadOnlyDictionary<uint, AnvilRecipe> recipes,
IReadOnlyList<AnvilFood> foods,
IReadOnlyList<AnvilFood> medicines,
List<string> warnings
)
{
var itemSheet = _data.GetExcelSheet<Item>();
var craftableIds = new HashSet<uint>(recipes.Values.Select(r => r.OutputItemId));
var wanted = new HashSet<uint>();
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<uint, AnvilItem>(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<uint, uint> 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;
}
}
}