forked from aly/qstbak
501 lines
14 KiB
C#
501 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Numerics;
|
|
using Dalamud.Game.ClientState.Conditions;
|
|
using Dalamud.Game.ClientState.Objects.Types;
|
|
using Dalamud.Hooking;
|
|
using Dalamud.Plugin;
|
|
using Dalamud.Plugin.Services;
|
|
using FFXIVClientStructs.FFXIV.Client.Game;
|
|
using FFXIVClientStructs.FFXIV.Client.Game.Event;
|
|
using FFXIVClientStructs.FFXIV.Client.Game.Object;
|
|
using Lumina.Excel.Sheets;
|
|
using Microsoft.Extensions.Logging;
|
|
using Questionable.Functions;
|
|
using Questionable.Model.Questing;
|
|
using SmartNav.Data;
|
|
|
|
namespace Questionable.Navigation;
|
|
|
|
internal sealed class WarpRecordingService : IDisposable
|
|
{
|
|
private unsafe delegate void ProcessEventPlayDelegate(EventFramework* self, GameObject* gameObject, FFXIVClientStructs.FFXIV.Client.Game.Event.EventId eventId, short scene, ulong sceneFlags, uint* sceneData, byte sceneDataCount);
|
|
|
|
private enum EWarpPhase
|
|
{
|
|
None,
|
|
Armed,
|
|
DialogOpen,
|
|
AwaitingTransit,
|
|
InTransit,
|
|
JumpSettling
|
|
}
|
|
|
|
public sealed record WarpCapture(uint WarpRowId, string WarpName, uint? NpcDataId, ushort SourceTerritoryId, Vector3 SourcePosition, ushort DestTerritoryId, Vector3 DestPosition, uint? SeenDuringMsqId);
|
|
|
|
public sealed record TicketCapture(uint ItemId, string ItemName, ushort DestTerritoryId, Vector3 DestPosition);
|
|
|
|
private const float JumpDistance = 40f;
|
|
|
|
private static readonly TimeSpan CancelGrace = TimeSpan.FromSeconds(3L);
|
|
|
|
private static readonly TimeSpan JumpSettle = TimeSpan.FromSeconds(1L);
|
|
|
|
private static readonly TimeSpan ArmedTimeout = TimeSpan.FromSeconds(120L);
|
|
|
|
private static readonly TimeSpan TicketTimeout = TimeSpan.FromSeconds(30L);
|
|
|
|
private readonly IClientState _clientState;
|
|
|
|
private readonly IObjectTable _objectTable;
|
|
|
|
private readonly ITargetManager _targetManager;
|
|
|
|
private readonly ICondition _condition;
|
|
|
|
private readonly IFramework _framework;
|
|
|
|
private readonly IDataManager _dataManager;
|
|
|
|
private readonly Configuration _configuration;
|
|
|
|
private readonly QuestFunctions _questFunctions;
|
|
|
|
private readonly TeleportTicketService _teleportTicketService;
|
|
|
|
private readonly ILogger<WarpRecordingService> _logger;
|
|
|
|
private readonly Hook<ProcessEventPlayDelegate>? _processEventPlayHook;
|
|
|
|
private EWarpPhase _warpPhase;
|
|
|
|
private uint _warpRowId;
|
|
|
|
private string _warpName = string.Empty;
|
|
|
|
private uint? _npcDataId;
|
|
|
|
private ushort _sourceTerritory;
|
|
|
|
private Vector3 _sourcePosition;
|
|
|
|
private uint? _seenMsqId;
|
|
|
|
private DateTime _armedAt;
|
|
|
|
private DateTime _awaitTransitSince;
|
|
|
|
private DateTime _jumpDetectedAt;
|
|
|
|
private uint _lastPlayerTerritory;
|
|
|
|
private Vector3? _lastPlayerPosition;
|
|
|
|
private readonly bool _devEnvironmentGate;
|
|
|
|
private uint? _ticketItemId;
|
|
|
|
private string _ticketItemName = string.Empty;
|
|
|
|
private bool _ticketSeenLoading;
|
|
|
|
private DateTime _ticketArmedAt;
|
|
|
|
private readonly Dictionary<uint, int> _ticketInventoryCounts = new Dictionary<uint, int>();
|
|
|
|
public bool IsWarpPending => _warpPhase != EWarpPhase.None;
|
|
|
|
public uint? PendingWarpRowId
|
|
{
|
|
get
|
|
{
|
|
if (!IsWarpPending)
|
|
{
|
|
return null;
|
|
}
|
|
return _warpRowId;
|
|
}
|
|
}
|
|
|
|
public string? PendingWarpName
|
|
{
|
|
get
|
|
{
|
|
if (!IsWarpPending)
|
|
{
|
|
return null;
|
|
}
|
|
return _warpName;
|
|
}
|
|
}
|
|
|
|
public bool IsTicketPending => _ticketItemId.HasValue;
|
|
|
|
public uint? PendingTicketItemId => _ticketItemId;
|
|
|
|
public string? PendingTicketItemName
|
|
{
|
|
get
|
|
{
|
|
if (!_ticketItemId.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
return _ticketItemName;
|
|
}
|
|
}
|
|
|
|
public bool CaptureAvailable => _devEnvironmentGate;
|
|
|
|
private bool AutoCaptureEnabled
|
|
{
|
|
get
|
|
{
|
|
if (_devEnvironmentGate)
|
|
{
|
|
return _configuration.Advanced.EnableNavDataCapture;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public event Action<WarpCapture>? WarpCaptured;
|
|
|
|
public event Action<TicketCapture>? TicketCaptured;
|
|
|
|
public unsafe WarpRecordingService(IDalamudPluginInterface pluginInterface, IClientState clientState, IObjectTable objectTable, ITargetManager targetManager, ICondition condition, IFramework framework, IDataManager dataManager, IGameInteropProvider gameInteropProvider, Configuration configuration, QuestFunctions questFunctions, TeleportTicketService teleportTicketService, ILogger<WarpRecordingService> logger)
|
|
{
|
|
_clientState = clientState;
|
|
_objectTable = objectTable;
|
|
_targetManager = targetManager;
|
|
_condition = condition;
|
|
_framework = framework;
|
|
_dataManager = dataManager;
|
|
_configuration = configuration;
|
|
_questFunctions = questFunctions;
|
|
_teleportTicketService = teleportTicketService;
|
|
_logger = logger;
|
|
_devEnvironmentGate = pluginInterface.IsDev || File.Exists(Path.Combine(pluginInterface.ConfigDirectory.FullName, ".nav-authoring"));
|
|
if (_devEnvironmentGate)
|
|
{
|
|
_logger.LogDebug("Warp recorder: dev environment gate open (IsDev={IsDev})", pluginInterface.IsDev);
|
|
}
|
|
try
|
|
{
|
|
_processEventPlayHook = gameInteropProvider.HookFromAddress<ProcessEventPlayDelegate>((nint)EventFramework.MemberFunctionPointers.ProcessEventPlay, OnProcessEventPlay);
|
|
_processEventPlayHook.Enable();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogError(exception, "Failed to hook ProcessEventPlay for warp recorder");
|
|
}
|
|
_framework.Update += OnFrameworkTick;
|
|
}
|
|
|
|
public void ArmManual(uint warpRowId, string? warpName, uint npcDataId, ushort sourceTerritory, Vector3 sourcePosition)
|
|
{
|
|
if (_devEnvironmentGate)
|
|
{
|
|
Arm(warpRowId, warpName ?? $"Warp#{warpRowId}", (npcDataId != 0) ? new uint?(npcDataId) : ((uint?)null), sourceTerritory, sourcePosition);
|
|
}
|
|
}
|
|
|
|
public bool CaptureWarpDestNow()
|
|
{
|
|
if (_warpPhase == EWarpPhase.None)
|
|
{
|
|
return false;
|
|
}
|
|
IGameObject gameObject = _objectTable[0];
|
|
if (gameObject == null || gameObject.Position == Vector3.Zero)
|
|
{
|
|
return false;
|
|
}
|
|
CompleteWarp(gameObject.Position);
|
|
return true;
|
|
}
|
|
|
|
public void DismissWarp()
|
|
{
|
|
ResetWarp("dismissed");
|
|
}
|
|
|
|
public bool CaptureTicketDestNow()
|
|
{
|
|
if (!_ticketItemId.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
IGameObject gameObject = _objectTable[0];
|
|
if (gameObject == null || gameObject.Position == Vector3.Zero)
|
|
{
|
|
return false;
|
|
}
|
|
TicketCapture ticketCapture = new TicketCapture(_ticketItemId.Value, _ticketItemName, (ushort)_clientState.TerritoryType, gameObject.Position);
|
|
_logger.LogInformation("Ticket recorder: manually captured {Name} (#{Id}) -> {Territory}", ticketCapture.ItemName, ticketCapture.ItemId, ticketCapture.DestTerritoryId);
|
|
ClearTicket();
|
|
this.TicketCaptured?.Invoke(ticketCapture);
|
|
return true;
|
|
}
|
|
|
|
public void DismissTicket()
|
|
{
|
|
ClearTicket();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_framework.Update -= OnFrameworkTick;
|
|
_processEventPlayHook?.Disable();
|
|
_processEventPlayHook?.Dispose();
|
|
}
|
|
|
|
private unsafe void OnProcessEventPlay(EventFramework* self, GameObject* gameObject, FFXIVClientStructs.FFXIV.Client.Game.Event.EventId eventId, short scene, ulong sceneFlags, uint* sceneData, byte sceneDataCount)
|
|
{
|
|
_processEventPlayHook.Original(self, gameObject, eventId, scene, sceneFlags, sceneData, sceneDataCount);
|
|
try
|
|
{
|
|
if (AutoCaptureEnabled && eventId.ContentId == EventHandlerContent.Warp)
|
|
{
|
|
uint id = eventId.Id;
|
|
string text = null;
|
|
Warp? rowOrDefault = _dataManager.GetExcelSheet<Warp>().GetRowOrDefault(id);
|
|
if (rowOrDefault.HasValue)
|
|
{
|
|
string text2 = rowOrDefault.Value.Name.ExtractText();
|
|
string text3 = rowOrDefault.Value.Question.ExtractText();
|
|
text = ((!string.IsNullOrEmpty(text2)) ? text2 : text3);
|
|
}
|
|
uint? npcDataId;
|
|
Vector3 sourcePosition;
|
|
if (gameObject != null)
|
|
{
|
|
npcDataId = ((gameObject->BaseId != 0) ? new uint?(gameObject->BaseId) : ((uint?)null));
|
|
sourcePosition = gameObject->Position;
|
|
}
|
|
else
|
|
{
|
|
IGameObject? target = _targetManager.Target;
|
|
npcDataId = target?.BaseId;
|
|
sourcePosition = target?.Position ?? _objectTable[0]?.Position ?? Vector3.Zero;
|
|
}
|
|
Arm(id, text ?? $"Warp#{id}", npcDataId, (ushort)_clientState.TerritoryType, sourcePosition);
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogError(exception, "Warp recorder: ProcessEventPlay handler failed");
|
|
}
|
|
}
|
|
|
|
private void Arm(uint warpRowId, string warpName, uint? npcDataId, ushort sourceTerritory, Vector3 sourcePosition)
|
|
{
|
|
_warpPhase = EWarpPhase.Armed;
|
|
_warpRowId = warpRowId;
|
|
_warpName = warpName;
|
|
_npcDataId = npcDataId;
|
|
_sourceTerritory = sourceTerritory;
|
|
_sourcePosition = sourcePosition;
|
|
_seenMsqId = CurrentMsqQuestId();
|
|
_armedAt = DateTime.UtcNow;
|
|
_logger.LogDebug("Warp recorder: armed warp {Id} ({Name}) at territory {Territory} ({X:F1}, {Y:F1}, {Z:F1}), MSQ {Msq}", warpRowId, warpName, sourceTerritory, sourcePosition.X, sourcePosition.Y, sourcePosition.Z, _seenMsqId);
|
|
}
|
|
|
|
private void OnFrameworkTick(IFramework _)
|
|
{
|
|
IGameObject gameObject = _objectTable[0];
|
|
bool flag = _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
|
DateTime utcNow = DateTime.UtcNow;
|
|
TickWarp(gameObject, flag, utcNow);
|
|
TickTicket(gameObject, flag, utcNow);
|
|
if (gameObject != null && !flag)
|
|
{
|
|
_lastPlayerTerritory = _clientState.TerritoryType;
|
|
_lastPlayerPosition = gameObject.Position;
|
|
}
|
|
else
|
|
{
|
|
_lastPlayerPosition = null;
|
|
}
|
|
}
|
|
|
|
private void TickWarp(IGameObject? player, bool loading, DateTime now)
|
|
{
|
|
if (_warpPhase == EWarpPhase.None)
|
|
{
|
|
return;
|
|
}
|
|
bool flag = _condition[ConditionFlag.OccupiedInQuestEvent] || _condition[ConditionFlag.OccupiedInEvent];
|
|
switch (_warpPhase)
|
|
{
|
|
case EWarpPhase.Armed:
|
|
if (loading)
|
|
{
|
|
EnterTransit();
|
|
}
|
|
else if (flag)
|
|
{
|
|
_warpPhase = EWarpPhase.DialogOpen;
|
|
}
|
|
else if (!TryDetectJump(player, now) && now - _armedAt > ArmedTimeout)
|
|
{
|
|
ResetWarp("no transit within timeout");
|
|
}
|
|
break;
|
|
case EWarpPhase.DialogOpen:
|
|
if (loading)
|
|
{
|
|
EnterTransit();
|
|
}
|
|
else if (!flag)
|
|
{
|
|
_warpPhase = EWarpPhase.AwaitingTransit;
|
|
_awaitTransitSince = now;
|
|
}
|
|
break;
|
|
case EWarpPhase.AwaitingTransit:
|
|
if (loading)
|
|
{
|
|
EnterTransit();
|
|
}
|
|
else if (!TryDetectJump(player, now) && now - _awaitTransitSince > CancelGrace)
|
|
{
|
|
ResetWarp("dialog closed without transit (cancelled)");
|
|
}
|
|
break;
|
|
case EWarpPhase.InTransit:
|
|
if (!loading && player != null && player.Position != Vector3.Zero)
|
|
{
|
|
CompleteWarp(player.Position);
|
|
}
|
|
break;
|
|
case EWarpPhase.JumpSettling:
|
|
if (loading)
|
|
{
|
|
EnterTransit();
|
|
}
|
|
else if (now - _jumpDetectedAt > JumpSettle && player != null && player.Position != Vector3.Zero)
|
|
{
|
|
CompleteWarp(player.Position);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void EnterTransit()
|
|
{
|
|
_warpPhase = EWarpPhase.InTransit;
|
|
_logger.LogDebug("Warp recorder: loading transition detected for pending warp {Id}", _warpRowId);
|
|
}
|
|
|
|
private bool TryDetectJump(IGameObject? player, DateTime now)
|
|
{
|
|
if (player == null || !_lastPlayerPosition.HasValue || _lastPlayerTerritory != _clientState.TerritoryType)
|
|
{
|
|
return false;
|
|
}
|
|
if (Vector3.DistanceSquared(player.Position, _lastPlayerPosition.Value) < 1600f)
|
|
{
|
|
return false;
|
|
}
|
|
_warpPhase = EWarpPhase.JumpSettling;
|
|
_jumpDetectedAt = now;
|
|
_logger.LogDebug("Warp recorder: position jump detected for pending warp {Id}", _warpRowId);
|
|
return true;
|
|
}
|
|
|
|
private void CompleteWarp(Vector3 destPosition)
|
|
{
|
|
WarpCapture warpCapture = new WarpCapture(_warpRowId, _warpName, _npcDataId, _sourceTerritory, _sourcePosition, (ushort)_clientState.TerritoryType, destPosition, _seenMsqId);
|
|
_logger.LogInformation("Warp recorder: captured warp {Id} ({Name}): {SrcTerritory} -> {DstTerritory} at ({X:F1}, {Y:F1}, {Z:F1}), MSQ {Msq}", warpCapture.WarpRowId, warpCapture.WarpName, warpCapture.SourceTerritoryId, warpCapture.DestTerritoryId, destPosition.X, destPosition.Y, destPosition.Z, warpCapture.SeenDuringMsqId);
|
|
ResetWarp(null);
|
|
this.WarpCaptured?.Invoke(warpCapture);
|
|
}
|
|
|
|
private void ResetWarp(string? reason)
|
|
{
|
|
if (reason != null && _warpPhase != EWarpPhase.None)
|
|
{
|
|
_logger.LogDebug("Warp recorder: clearing pending warp {Id}: {Reason}", _warpRowId, reason);
|
|
}
|
|
_warpPhase = EWarpPhase.None;
|
|
_npcDataId = null;
|
|
_seenMsqId = null;
|
|
_warpName = string.Empty;
|
|
}
|
|
|
|
private unsafe void TickTicket(IGameObject? player, bool loading, DateTime now)
|
|
{
|
|
if (!_ticketItemId.HasValue)
|
|
{
|
|
if (!AutoCaptureEnabled)
|
|
{
|
|
if (_ticketInventoryCounts.Count > 0)
|
|
{
|
|
_ticketInventoryCounts.Clear();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (loading)
|
|
{
|
|
return;
|
|
}
|
|
InventoryManager* ptr = InventoryManager.Instance();
|
|
if (ptr == null)
|
|
{
|
|
return;
|
|
}
|
|
foreach (TeleportTicketService.ScannedTicket scannedTicket in _teleportTicketService.ScannedTickets)
|
|
{
|
|
int inventoryItemCount = ptr->GetInventoryItemCount(scannedTicket.ItemId, isHq: false, checkEquipped: true, checkArmory: true, 0);
|
|
if (_ticketInventoryCounts.TryGetValue(scannedTicket.ItemId, out var value) && inventoryItemCount < value)
|
|
{
|
|
_logger.LogInformation("Ticket recorder: {Name} (#{Id}) count decreased {Old} -> {New}, arming capture", scannedTicket.Name, scannedTicket.ItemId, value, inventoryItemCount);
|
|
_ticketItemId = scannedTicket.ItemId;
|
|
_ticketItemName = scannedTicket.Name;
|
|
_ticketSeenLoading = false;
|
|
_ticketArmedAt = now;
|
|
}
|
|
_ticketInventoryCounts[scannedTicket.ItemId] = inventoryItemCount;
|
|
}
|
|
}
|
|
}
|
|
else if (loading)
|
|
{
|
|
_ticketSeenLoading = true;
|
|
}
|
|
else if (_ticketSeenLoading)
|
|
{
|
|
if (player != null && !(player.Position == Vector3.Zero))
|
|
{
|
|
TicketCapture ticketCapture = new TicketCapture(_ticketItemId.Value, _ticketItemName, (ushort)_clientState.TerritoryType, player.Position);
|
|
_logger.LogInformation("Ticket recorder: captured {Name} (#{Id}) -> {Territory} ({X:F1}, {Y:F1}, {Z:F1})", ticketCapture.ItemName, ticketCapture.ItemId, ticketCapture.DestTerritoryId, player.Position.X, player.Position.Y, player.Position.Z);
|
|
ClearTicket();
|
|
this.TicketCaptured?.Invoke(ticketCapture);
|
|
}
|
|
}
|
|
else if (now - _ticketArmedAt > TicketTimeout)
|
|
{
|
|
_logger.LogDebug("Ticket recorder: no transit after ticket use, clearing pending {Id}", _ticketItemId);
|
|
ClearTicket();
|
|
}
|
|
}
|
|
|
|
private void ClearTicket()
|
|
{
|
|
_ticketItemId = null;
|
|
_ticketItemName = string.Empty;
|
|
_ticketSeenLoading = false;
|
|
}
|
|
|
|
private uint? CurrentMsqQuestId()
|
|
{
|
|
if (!(_questFunctions.GetMainScenarioQuest().Item1.CurrentQuest is QuestId questId))
|
|
{
|
|
return null;
|
|
}
|
|
return questId.Value;
|
|
}
|
|
}
|