Add IsRecipeUnlocked

This commit is contained in:
Haselnussbomber 2025-10-01 23:59:01 +02:00
parent 6ade5b21cf
commit ba159f8c5f
No known key found for this signature in database
GPG key ID: BB905BB49E7295D1
3 changed files with 280 additions and 1 deletions

View file

@ -0,0 +1,257 @@
using System.Linq;
using CommunityToolkit.HighPerformance;
using Dalamud.Data;
using Dalamud.Game.Gui;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
using FFXIVClientStructs.Interop;
using Lumina.Excel.Sheets;
namespace Dalamud.Game.UnlockState;
/// <summary>
/// Represents recipe-related data for all crafting classes.
/// </summary>
[ServiceManager.EarlyLoadedService]
internal unsafe class RecipeData : IInternalDisposableService
{
[ServiceManager.ServiceDependency]
private readonly DataManager dataManager = Service<DataManager>.Get();
[ServiceManager.ServiceDependency]
private readonly ClientState.ClientState clientState = Service<ClientState.ClientState>.Get();
[ServiceManager.ServiceDependency]
private readonly GameGui gameGui = Service<GameGui>.Get();
private readonly ushort[] craftTypeLevels;
private readonly byte[] unlockedNoteBookDivisionsCount;
private readonly byte[] unlockedSecretNoteBookDivisionsCount;
private readonly ushort[,] noteBookDivisionIds;
private byte[]? cachedUnlockedSecretRecipeBooks;
private byte[]? cachedUnlockLinks;
/// <summary>
/// Initializes a new instance of the <see cref="RecipeData"/> class.
/// </summary>
[ServiceManager.ServiceConstructor]
public RecipeData()
{
var numCraftTypes = this.dataManager.GetExcelSheet<CraftType>().Count();
var numSecretNotBookDivisions = this.dataManager.GetExcelSheet<NotebookDivision>().Count(row => row.RowId is >= 1000 and < 2000);
this.unlockedNoteBookDivisionsCount = new byte[numCraftTypes];
this.unlockedSecretNoteBookDivisionsCount = new byte[numCraftTypes];
this.noteBookDivisionIds = new ushort[numCraftTypes, numSecretNotBookDivisions];
this.craftTypeLevels = new ushort[numCraftTypes];
this.clientState.Login += this.Update;
this.clientState.Logout += this.OnLogout;
this.gameGui.UnlocksUpdate += this.Update;
}
/// <inheritdoc/>
void IInternalDisposableService.DisposeService()
{
this.clientState.Login -= this.Update;
this.clientState.Logout -= this.OnLogout;
this.gameGui.UnlocksUpdate -= this.Update;
}
/// <summary>
/// Determines whether the specified Recipe is unlocked.
/// </summary>
/// <param name="row">The Recipe row to check.</param>
/// <returns><see langword="true"/> if unlocked; otherwise, <see langword="false"/>.</returns>
public bool IsRecipeUnlocked(Recipe row)
{
// E8 ?? ?? ?? ?? 48 63 76 (2025.09.04)
var division = row.RecipeNotebookList.RowId != 0 && row.RecipeNotebookList.IsValid
? (row.RecipeNotebookList.RowId - 1000) / 8 + 1000
: ((uint)row.RecipeLevelTable.Value.ClassJobLevel - 1) / 5;
// E8 ?? ?? ?? ?? 33 ED 84 C0 75 (2025.09.04)
foreach (var craftTypeRow in this.dataManager.GetExcelSheet<CraftType>())
{
var craftType = (byte)craftTypeRow.RowId;
if (division < this.unlockedNoteBookDivisionsCount[craftType])
return true;
if (this.unlockedNoteBookDivisionsCount[craftType] == 0)
continue;
if (division is 5000 or 5001)
return true;
if (division < 1000)
continue;
if (this.unlockedSecretNoteBookDivisionsCount[craftType] == 0)
continue;
if (this.noteBookDivisionIds.GetRowSpan(craftType).Contains((ushort)division))
return true;
}
return false;
}
private void OnLogout(int type, int code)
{
this.cachedUnlockedSecretRecipeBooks = null;
this.cachedUnlockLinks = null;
}
private void Update()
{
// Client::Game::UI::RecipeNote.InitializeStructs
if (!this.NeedsUpdate())
return;
Array.Clear(this.unlockedNoteBookDivisionsCount, 0, this.unlockedNoteBookDivisionsCount.Length);
Array.Clear(this.unlockedSecretNoteBookDivisionsCount, 0, this.unlockedSecretNoteBookDivisionsCount.Length);
Array.Clear(this.noteBookDivisionIds, 0, this.noteBookDivisionIds.Length);
foreach (var craftTypeRow in this.dataManager.GetExcelSheet<CraftType>())
{
var craftType = (byte)craftTypeRow.RowId;
var craftTypeLevel = RecipeNote.Instance()->GetCraftTypeLevel(craftType);
if (craftTypeLevel == 0)
continue;
var noteBookDivisionIndex = -1;
foreach (var noteBookDivisionRow in this.dataManager.GetExcelSheet<NotebookDivision>())
{
if (noteBookDivisionRow.RowId < 1000)
{
if (craftTypeLevel >= noteBookDivisionRow.CraftOpeningLevel)
this.unlockedNoteBookDivisionsCount[craftType]++;
}
else if (noteBookDivisionRow.RowId < 2000)
{
noteBookDivisionIndex++;
// For future Lumina.Excel update, replace with:
// if (!notebookDivisionRow.AllowedCraftTypes[craftType])
// continue;
switch (craftTypeRow.RowId)
{
case 0 when !noteBookDivisionRow.CRPCraft: continue;
case 1 when !noteBookDivisionRow.BSMCraft: continue;
case 2 when !noteBookDivisionRow.ARMCraft: continue;
case 3 when !noteBookDivisionRow.GSMCraft: continue;
case 4 when !noteBookDivisionRow.LTWCraft: continue;
case 5 when !noteBookDivisionRow.WVRCraft: continue;
case 6 when !noteBookDivisionRow.ALCCraft: continue;
case 7 when !noteBookDivisionRow.CULCraft: continue;
}
if (noteBookDivisionRow.GatheringOpeningLevel != byte.MaxValue)
continue;
// For future Lumina.Excel update, replace with:
// if (notebookDivisionRow.RequiresSecretRecipeBookGroupUnlock)
if (noteBookDivisionRow.Unknown1)
{
var secretRecipeBookUnlocked = false;
// For future Lumina.Excel update, iterate over notebookDivisionRow.SecretRecipeBookGroups
for (var i = 0; i < 2; i++)
{
// For future Lumina.Excel update, replace with:
// if (secretRecipeBookGroup.RowId == 0 || !secretRecipeBookGroup.IsValid)
// continue;
var secretRecipeBookGroupRowId = i switch
{
0 => noteBookDivisionRow.Unknown2,
1 => noteBookDivisionRow.Unknown2,
_ => default,
};
if (secretRecipeBookGroupRowId == 0)
continue;
if (!this.dataManager.GetExcelSheet<SecretRecipeBookGroup>().TryGetRow(secretRecipeBookGroupRowId, out var secretRecipeBookGroupRow))
continue;
// For future Lumina.Excel update, replace with:
// var bitIndex = secretRecipeBookGroup.Value.UnlockBitIndex[craftType];
var bitIndex = craftType switch
{
0 => secretRecipeBookGroupRow.Unknown0,
1 => secretRecipeBookGroupRow.Unknown1,
2 => secretRecipeBookGroupRow.Unknown2,
3 => secretRecipeBookGroupRow.Unknown3,
4 => secretRecipeBookGroupRow.Unknown4,
5 => secretRecipeBookGroupRow.Unknown5,
6 => secretRecipeBookGroupRow.Unknown6,
7 => secretRecipeBookGroupRow.Unknown7,
_ => default,
};
if (PlayerState.Instance()->UnlockedSecretRecipeBooksBitmask.TryCheckBitInSpan(bitIndex, out var result) && result)
{
secretRecipeBookUnlocked = true;
break;
}
}
if (noteBookDivisionRow.CraftOpeningLevel > craftTypeLevel && !secretRecipeBookUnlocked)
continue;
}
else if (craftTypeLevel < noteBookDivisionRow.CraftOpeningLevel)
{
continue;
}
else if (noteBookDivisionRow.QuestUnlock.RowId != 0 && !UIState.Instance()->IsUnlockLinkUnlockedOrQuestCompleted(noteBookDivisionRow.QuestUnlock.RowId))
{
continue;
}
this.unlockedSecretNoteBookDivisionsCount[craftType]++;
this.noteBookDivisionIds[craftType, noteBookDivisionIndex] = (ushort)noteBookDivisionRow.RowId;
}
}
}
}
private bool NeedsUpdate()
{
var changed = false;
foreach (var craftTypeRow in this.dataManager.GetExcelSheet<CraftType>())
{
var craftType = (byte)craftTypeRow.RowId;
var craftTypeLevel = RecipeNote.Instance()->GetCraftTypeLevel(craftType);
if (this.craftTypeLevels[craftType] != craftTypeLevel)
{
this.craftTypeLevels[craftType] = craftTypeLevel;
changed |= true;
}
}
if (this.cachedUnlockedSecretRecipeBooks == null || !PlayerState.Instance()->UnlockedSecretRecipeBooksBitmask.SequenceEqual(this.cachedUnlockedSecretRecipeBooks))
{
this.cachedUnlockedSecretRecipeBooks = PlayerState.Instance()->UnlockedSecretRecipeBooksBitmask.ToArray();
changed |= true;
}
if (this.cachedUnlockLinks == null || !UIState.Instance()->UnlockLinkBitmask.SequenceEqual(this.cachedUnlockLinks))
{
this.cachedUnlockLinks = UIState.Instance()->UnlockLinkBitmask.ToArray();
changed |= true;
}
return changed;
}
}

View file

@ -39,6 +39,9 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState
[ServiceManager.ServiceDependency]
private readonly GameGui gameGui = Service<GameGui>.Get();
[ServiceManager.ServiceDependency]
private readonly RecipeData recipeData = Service<RecipeData>.Get();
[ServiceManager.ServiceConstructor]
private UnlockState()
{
@ -346,6 +349,12 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState
return UIState.IsPublicContentUnlocked(row.RowId);
}
/// <inheritdoc/>
public bool IsRecipeUnlocked(Recipe row)
{
return this.recipeData.IsRecipeUnlocked(row);
}
/// <inheritdoc/>
public bool IsSecretRecipeBookUnlocked(SecretRecipeBook row)
{
@ -495,6 +504,9 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState
if (rowRef.TryGetValue<PublicContentSheet>(out var publicContentRow))
return this.IsPublicContentUnlocked(publicContentRow);
if (rowRef.TryGetValue<Recipe>(out var recipeRow))
return this.IsRecipeUnlocked(recipeRow);
if (rowRef.TryGetValue<SecretRecipeBook>(out var secretRecipeBookRow))
return this.IsSecretRecipeBookUnlocked(secretRecipeBookRow);
@ -584,6 +596,7 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState
this.UpdateUnlocksForSheet<Ornament>(fireEvent);
this.UpdateUnlocksForSheet<Perform>(fireEvent);
this.UpdateUnlocksForSheet<PublicContentSheet>(fireEvent);
this.UpdateUnlocksForSheet<Recipe>(fireEvent);
this.UpdateUnlocksForSheet<SecretRecipeBook>(fireEvent);
this.UpdateUnlocksForSheet<Trait>(fireEvent);
this.UpdateUnlocksForSheet<TripleTriadCard>(fireEvent);
@ -596,7 +609,6 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState
// - FishingSpot
// - Spearfishing
// - Adventure (Sightseeing)
// - Recipes
// - MinerFolkloreTome
// - BotanistFolkloreTome
// - FishingFolkloreTome
@ -767,6 +779,9 @@ internal class UnlockStatePluginScoped : IInternalDisposableService, IUnlockStat
/// <inheritdoc/>
public bool IsPublicContentUnlocked(PublicContentSheet row) => this.unlockStateService.IsPublicContentUnlocked(row);
/// <inheritdoc/>
public bool IsRecipeUnlocked(Recipe row) => this.unlockStateService.IsRecipeUnlocked(row);
/// <inheritdoc/>
public bool IsRowRefUnlocked(RowRef rowRef) => this.unlockStateService.IsRowRefUnlocked(rowRef);

View file

@ -245,6 +245,13 @@ public interface IUnlockState
/// <returns><see langword="true"/> if unlocked; otherwise, <see langword="false"/>.</returns>
bool IsPublicContentUnlocked(PublicContent row);
/// <summary>
/// Determines whether the specified Recipe is unlocked.
/// </summary>
/// <param name="row">The Recipe row to check.</param>
/// <returns><see langword="true"/> if unlocked; otherwise, <see langword="false"/>.</returns>
bool IsRecipeUnlocked(Recipe row);
/// <summary>
/// Determines whether the underlying RowRef type is unlocked.
/// </summary>