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