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&¤t.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);
}
}
}
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&¤t.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);
}
}
}