XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Diagnostics;
using XFEEDToolBox.Core.Models;

namespace XFEEDToolBox.Core.Session;

public sealed record NativeSessionMenuAction(string Name, bool Enabled);
public sealed record NativeSessionFrame(long Sequence, DateTimeOffset ObservedAt, string State, string NextState,
    IReadOnlyList<NativeSessionMenuAction> Actions, string LastRequestId = "", bool ActionApplied = false, bool ActionFailed = false)
{
    public bool Stable => State.Length > 0 && State == NextState;
    public bool Available{get;init;}=true;
}

public interface INativeRelogChannel
{
    Task BeginAsync(string owner, CancellationToken token);
    Task<NativeSessionFrame> ReadAsync(string owner, CancellationToken token);
    Task SubmitAsync(string owner, string requestId, NativeSessionFrame frame, string action, CancellationToken token);
    Task EndAsync(string owner);
}

/// <summary>Original menu observations made while BeginAsync is still leaving the old session.</summary>
public interface INativeMenuExitWitness
{
    bool ReachedMainMenu{get;}
    Task<NativeSessionFrame> ReadOriginalMenuStateAsync(CancellationToken token);
}

public static class NativeMenuExitSafety
{
    public static bool Fresh(NativeSessionFrame frame,DateTimeOffset now)=>frame.Sequence>0
        &&now-frame.ObservedAt>=TimeSpan.FromMilliseconds(-100)&&now-frame.ObservedAt<=TimeSpan.FromMilliseconds(350);
    public static bool MenuOrLoading(string state)=>state is "MainMenu" or "ContinueMenu" or "SetupPlaySolo" or "SetupPlayWithEveryone"
        or "PreMultiplayerWarnings" or "MultiplayerStart" or "MultiplayerLoad" or "EnteringLocation" or "SaveGameOnEnterLocation";
    private static bool CriticalCause(Exception error)=>error is InvalidDataException or NotSupportedException or Navigation.FlightSafetyPauseException
        ||error is InvalidOperationException&&!Navigation.NavigationRecovery.CanRetry(error,default)
        ||error.InnerException is {} inner&&CriticalCause(inner);
    public static bool CanFallback(Exception error,CancellationToken token)=>!token.IsCancellationRequested&&!CriticalCause(error)
        &&error is TimeoutException or IOException or OperationCanceledException;
    public static bool CanExitClient(PlayerState expected,PlayerState current,NativeSessionFrame actual,DateTimeOffset now)
    {
        if(!Fresh(actual,now)||MenuOrLoading(actual.State)||MenuOrLoading(actual.NextState)
            ||actual.State is not("InGame" or "PrePauseMenu" or "Pause" or "ExitOptions")
            ||actual.NextState is not("InGame" or "PrePauseMenu" or "Pause" or "ExitOptions")
            ||current.InGame&&current.LoadGameAt>expected.LoadGameAt)return false;
        if(expected.CommanderId.Length==0||expected.CurrentShipId is null||current.CommanderId!=expected.CommanderId
            ||current.CurrentShipId!=expected.CurrentShipId||current.GameMode!=expected.GameMode||!current.CargoReliable||current.CargoCount!=expected.CargoCount
            ||Navigation.NavigationSafety.HasExternalThreat(current)||Navigation.NavigationSafety.UnhealthyHull(current))return false;
        var before=expected.Ships.FirstOrDefault(ship=>ship.ShipId==expected.CurrentShipId)?.HullHealth;
        var after=current.Ships.FirstOrDefault(ship=>ship.ShipId==current.CurrentShipId)?.HullHealth;
        return before is >0 and <=1&&after is >0 and <=1&&after>=before-.000001;
    }
}

public sealed class NativeSessionStateChangedException(string message) : IOException(message);
public sealed class NativeMenuExitTimeoutException():TimeoutException("原始菜单退出未在离场保护期限前完成。");

/// <summary>Uses the game's eligible menu entries. Never kills a process or synthesizes keys.</summary>
public sealed class NativeRelogWorkflow(INativeRelogChannel channel, Func<PlayerState> player)
{
    public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(120);
    public TimeSpan MenuExitTimeout { get; init; } = System.Threading.Timeout.InfiniteTimeSpan;
    public bool ReachedMainMenu { get; private set; }
    public Func<Task>? OnReachedMainMenu {get;init;}
    public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(75);
    public TimeSpan ReceiptRetryAfter { get; init; } = TimeSpan.FromMilliseconds(400);
    public TimeProvider Clock { get; init; } = TimeProvider.System;
    public event Action<string>? Progress;

    public static bool SupportsMode(string mode) => mode is "Solo" or "Open";

    public static bool IsReturnToMenu(string action)
    {
        var name = new string(action.Where(char.IsLetterOrDigit).ToArray());
        return !name.Contains("Cancel", StringComparison.OrdinalIgnoreCase)
            && !name.Contains("Desktop", StringComparison.OrdinalIgnoreCase)
            && (name.Contains("Quit", StringComparison.OrdinalIgnoreCase) || name.Contains("Exit", StringComparison.OrdinalIgnoreCase))
            && name.Contains("Menu", StringComparison.OrdinalIgnoreCase);
    }

    public static string? ChooseAction(NativeSessionFrame frame, string mode, bool reachedMainMenu)
    {
        if (!frame.Stable) return null;
        if (!reachedMainMenu && frame.State == "InGame") return "@pause";
        var enabled = frame.Actions.Where(a => a.Enabled).ToArray();
        if (frame.State == "MainMenu") return enabled.SingleOrDefault(a => a.Name == "ContinueMenu")?.Name;
        if (frame.State == "ContinueMenu")
        {
            var entry = mode switch { "Solo" => "SetupPlaySolo", "Open" => "SetupPlayWithEveryone", _ => "" };
            return enabled.SingleOrDefault(a => a.Name == entry)?.Name;
        }
        if (!reachedMainMenu)
        {
            // The verified Odyssey pause menu opens ExitOptions first; the
            // enabled original confirmation row belongs to that submenu.
            if (frame.State == "Pause" && enabled.Any(a => a.Name == "ExitOptions"))
                return enabled.Single(a => a.Name == "ExitOptions").Name;
            if (frame.State == "ExitOptions")
                return enabled.SingleOrDefault(a => a.Name == "Confirmed Quit To Main Menu")?.Name;
            var exits = enabled.Where(a => IsReturnToMenu(a.Name)).ToArray();
            if (exits.Length > 1) throw new InvalidOperationException("返回主菜单的原生菜单项不唯一,已停止重进。");
            return exits.SingleOrDefault()?.Name;
        }
        return null;
    }

    public async Task RunAsync(CancellationToken token = default)
    {
        ReachedMainMenu=false;
        var initial = player();
        if (!initial.InGame || initial.LoadGameAt is null || initial.CommanderId.Length == 0)
            throw new InvalidOperationException("请先进入游戏,再执行一键重进。");
        if (!SupportsMode(initial.GameMode))
            throw new NotSupportedException("当前支持单人和公开模式重进;私人群组的原生选择流程尚未接入。");
        var owner = Guid.NewGuid().ToString("N");
        var watch = Stopwatch.StartNew();
        var mainMenu = false;
        using var run=CancellationTokenSource.CreateLinkedTokenSource(token);run.CancelAfter(Timeout);
        var working=run.Token;
        var issued = false;
        string? request = null, lastState = null, lastActionState = null;
        string? pendingAction = null, pendingState = null;
        var lastAttempt = TimeSpan.Zero;
        var receiptRetries = 0;
        async Task ObserveMenuWitnessAsync(NativeSessionFrame? frame=null)
        {
            if(ReachedMainMenu)return;
            var observed=(channel as INativeMenuExitWitness)?.ReachedMainMenu==true
                ||frame is not null&&NativeMenuExitSafety.Fresh(frame,Clock.GetUtcNow())&&frame.Stable&&frame.State=="MainMenu";
            if(!observed)return;
            mainMenu=ReachedMainMenu=true;
            if(OnReachedMainMenu is not null)await OnReachedMainMenu().ConfigureAwait(false);
            Progress?.Invoke("已返回主菜单,正在重新进入" + (initial.GameMode == "Solo" ? "单人模式" : "公开模式"));
        }
        async Task BeginWithDeadlineAsync()
        {
            using var pending=CancellationTokenSource.CreateLinkedTokenSource(working);
            var begin=channel.BeginAsync(owner,pending.Token);
            while(!begin.IsCompleted)
            {
                working.ThrowIfCancellationRequested();await ObserveMenuWitnessAsync();
                if(!ReachedMainMenu&&MenuExitTimeout!=System.Threading.Timeout.InfiniteTimeSpan&&watch.Elapsed>=MenuExitTimeout)
                {
                    pending.Cancel();
                    try{await begin.ConfigureAwait(false);}catch(OperationCanceledException)when(!token.IsCancellationRequested){}
                    await ObserveMenuWitnessAsync();
                    if(ReachedMainMenu)
                    {
                        // A fresh menu witness can race the deadline. Resume
                        // this same owner; the recovery bridge must not leave twice.
                        await channel.BeginAsync(owner,working).ConfigureAwait(false);return;
                    }
                    throw new NativeMenuExitTimeoutException();
                }
                var delay=MenuExitTimeout!=System.Threading.Timeout.InfiniteTimeSpan&&!ReachedMainMenu
                    ?TimeSpan.FromMilliseconds(Math.Max(1,Math.Min(PollInterval.TotalMilliseconds,(MenuExitTimeout-watch.Elapsed).TotalMilliseconds))):PollInterval;
                await Task.WhenAny(begin,Task.Delay(delay,working)).ConfigureAwait(false);
            }
            try{await begin.ConfigureAwait(false);}
            finally{await ObserveMenuWitnessAsync();}
        }
        try
        {
            // End also runs after an uncertain begin acknowledgement, using the same owner.
            issued = true;
            await BeginWithDeadlineAsync();
            Progress?.Invoke("正在通过游戏原生菜单返回主菜单");
            while (watch.Elapsed < Timeout)
            {
                working.ThrowIfCancellationRequested();await ObserveMenuWitnessAsync();
                if(!mainMenu&&MenuExitTimeout!=System.Threading.Timeout.InfiniteTimeSpan&&watch.Elapsed>=MenuExitTimeout)
                    throw new NativeMenuExitTimeoutException();
                var current = player();
                if (current.CommanderId != initial.CommanderId)
                    throw new InvalidOperationException("指挥官身份已改变,已停止重进。");
                NativeSessionFrame frame;
                try { frame = await channel.ReadAsync(owner, working); }
                catch (Exception error) when (!working.IsCancellationRequested && error is not InvalidDataException&&error is IOException or OperationCanceledException)
                {
                    await Task.Delay(PollInterval, working);
                    continue;
                }
                if (frame.Sequence <= 0 || Clock.GetUtcNow() - frame.ObservedAt > TimeSpan.FromMilliseconds(350)
                    || frame.ObservedAt > Clock.GetUtcNow().AddMilliseconds(100))
                {
                    await Task.Delay(PollInterval, working);
                    continue;
                }
                if (frame.State != lastState)
                {
                    lastState = frame.State;
                    lastActionState = null;
                }
                if (frame.Stable && frame.State == "MainMenu")
                {
                    await ObserveMenuWitnessAsync(frame);
                }
                if (mainMenu && frame.Stable && frame.State == "InGame" && current.InGame && current.LoadGameAt > initial.LoadGameAt)
                {
                    if (current.GameMode != initial.GameMode)
                        throw new InvalidOperationException("重新进入的游戏模式与原模式不同,请检查游戏界面。");
                    Progress?.Invoke("已重新进入游戏");
                    return;
                }
                if (request is not null)
                {
                    if (frame.LastRequestId == request && (frame.ActionApplied || frame.ActionFailed))
                    {
                        if (frame.ActionFailed) lastActionState = null;
                        request = null;
                    }
                    else
                    {
                        if (frame.LastRequestId != request && watch.Elapsed - lastAttempt >= ReceiptRetryAfter)
                        {
                            if (frame.State != pendingState || receiptRetries++ >= 3)
                                throw new IOException("游戏尚未确认菜单请求,已停止后续选择,请查看当前游戏状态。");
                            lastAttempt = watch.Elapsed;
                            try { await channel.SubmitAsync(owner, request, frame, pendingAction!, working); }
                            catch (NativeSessionStateChangedException) { request = null; lastActionState = null; }
                            catch (OperationCanceledException) when (!working.IsCancellationRequested) { }
                            catch (IOException) { }
                        }
                        await Task.Delay(PollInterval, working);
                        continue;
                    }
                }
                var action = ChooseAction(frame, initial.GameMode, mainMenu);
                if (action is not null && lastActionState != frame.State)
                {
                    request = Guid.NewGuid().ToString("N");
                    pendingAction = action; pendingState = frame.State;
                    lastAttempt = watch.Elapsed; receiptRetries = 0;
                    lastActionState = frame.State;
                    // A lost response is resolved by ReadAsync's request receipt;
                    // do not send a second menu selection on an IPC timeout.
                    try { await channel.SubmitAsync(owner, request, frame, action, working); }
                    catch (NativeSessionStateChangedException) { request = null; lastActionState = null; }
                    catch (OperationCanceledException) when (!working.IsCancellationRequested) { }
                    catch (IOException) { }
                }
                await Task.Delay(PollInterval, working);
            }
            throw new TimeoutException("游戏尚未确认重进完成;已停止后续菜单操作,请查看当前游戏状态。");
        }
        catch(OperationCanceledException error)when(!token.IsCancellationRequested&&run.IsCancellationRequested)
        {await ObserveMenuWitnessAsync();throw new TimeoutException("游戏原模式载入未在总期限内完成;当前客户端已保留。",error);}
        finally
        {
            if (issued) await channel.EndAsync(owner);
        }
    }
}