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:
+96
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user