/ GUNRPG.ConsoleClient / Program.cs
Program.cs
   1  using System.Net.Http.Json;
   2  using System.Security.Cryptography;
   3  using System.Text.Json;
   4  using System.Text.Json.Serialization;
   5  using GUNRPG.Application.Backend;
   6  using GUNRPG.Application.Combat;
   7  using GUNRPG.Application.Dtos;
   8  using GUNRPG.Application.Requests;
   9  using GUNRPG.Application.Sessions;
  10  using GUNRPG.ConsoleClient.Auth;
  11  using GUNRPG.ConsoleClient.Identity;
  12  using GUNRPG.Core.Intents;
  13  using GUNRPG.Infrastructure;
  14  using GUNRPG.Infrastructure.Backend;
  15  using GUNRPG.Infrastructure.Persistence;
  16  using Hex1b;
  17  using Hex1b.Widgets;
  18  using JsonSerializer = System.Text.Json.JsonSerializer;
  19  // Type aliases that map the local DTO names used throughout this file to the
  20  // shared GUNRPG.ClientModels types, eliminating duplicate class definitions.
  21  using CombatSessionDto = GUNRPG.ClientModels.CombatSession;
  22  using PlayerStateDto = GUNRPG.ClientModels.PlayerState;
  23  using PetState = GUNRPG.ClientModels.PetState;
  24  using BattleLogEntryDto = GUNRPG.ClientModels.BattleLogEntry;
  25  using OperatorState = GUNRPG.ClientModels.OperatorState;
  26  using OperatorSummary = GUNRPG.ClientModels.OperatorSummary;
  27  
  28  var baseAddress = ResolveServerAddress(args, Environment.GetEnvironmentVariable("GUNRPG_API_BASE"))
  29      .TrimEnd('/'); // normalize to avoid double-slash URLs and ensure node URL comparisons work
  30  var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
  31  jsonOptions.Converters.Add(new JsonStringEnumConverter());
  32  
  33  // Chain the auth handler into the HttpClient so all callers (including GameState)
  34  // benefit from Bearer token injection and transparent 401 refresh/device-flow retry.
  35  var tokenStore = new TokenStore();
  36  var authHandler = new AuthDelegatingHandler(tokenStore, baseAddress)
  37  {
  38      InnerHandler = new HttpClientHandler()
  39  };
  40  using var httpClient = new HttpClient(authHandler) { BaseAddress = new Uri(baseAddress) };
  41  using var cts = new CancellationTokenSource();
  42  
  43  // Initialize offline services via centralized factory (no manual new() for infrastructure types)
  44  var offlineDbPath = Path.Combine(
  45      Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
  46      ".gunrpg", "offline.db");
  47  var (offlineDb, offlineStore, backendResolver) = InfrastructureServiceExtensions.CreateConsoleServices(
  48      httpClient, offlineDbPath, jsonOptions);
  49  using var _ = offlineDb; // ensure disposal
  50  
  51  // Resolve game backend based on server reachability and local state
  52  var backend = await backendResolver.ResolveAsync();
  53  
  54  // Set up the TUI-integrated session manager.
  55  // In online mode: attempt silent auto-login from session.json.
  56  // On failure (no stored session or expired token): show LoginMenu so the user can log in via the TUI.
  57  // In offline mode: skip auth entirely and go straight to the main menu.
  58  var sessionStore = new SessionStore();
  59  var sessionManager = new SessionManager(sessionStore, authHandler, baseAddress);
  60  Screen initialScreen;
  61  
  62  if (backendResolver.CurrentMode == GameMode.Online)
  63  {
  64      try
  65      {
  66          initialScreen = await sessionManager.TryAutoLoginAsync(cts.Token)
  67              ? Screen.MainMenu
  68              : Screen.LoginMenu;
  69      }
  70      catch (OperationCanceledException)
  71      {
  72          throw;
  73      }
  74      catch
  75      {
  76          initialScreen = Screen.LoginMenu;
  77      }
  78  }
  79  else
  80  {
  81      // Offline mode — authentication is not required.
  82      initialScreen = Screen.MainMenu;
  83  }
  84  
  85  var gameState = new GameState(httpClient, jsonOptions, backend, backendResolver, offlineStore, offlineDb, sessionManager, initialScreen);
  86  
  87  // Auto-load the last used operator only when the user is already authenticated (or in offline mode).
  88  if (initialScreen != Screen.LoginMenu)
  89      gameState.LoadSavedOperatorId();
  90  
  91  Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
  92  
  93  using var app = new Hex1bApp(ctx => gameState.BuildUI(ctx, cts));
  94  var raidTickTask = Task.Run(async () =>
  95  {
  96      while (!cts.IsCancellationRequested)
  97      {
  98          gameState.Tick();
  99          app.Invalidate();
 100          try
 101          {
 102              await Task.Delay(50, cts.Token);
 103          }
 104          catch (OperationCanceledException)
 105          {
 106              break;
 107          }
 108      }
 109  }, cts.Token);
 110  await app.RunAsync(cts.Token);
 111  if (!cts.IsCancellationRequested)
 112  {
 113      cts.Cancel();
 114  }
 115  try
 116  {
 117      await raidTickTask;
 118  }
 119  catch (OperationCanceledException)
 120  {
 121  }
 122  
 123  /// <summary>
 124  /// Resolves the server base address.
 125  /// Precedence: --server arg > GUNRPG_API_BASE env var > mDNS LAN discovery > localhost fallback.
 126  /// </summary>
 127  static string ResolveServerAddress(string[] args, string? envAddress)
 128  {
 129      var clientVersion = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString(3) ?? "0.1.0";
 130      // 1. Check --server command-line argument
 131      for (var i = 0; i < args.Length; i++)
 132      {
 133          if (args[i] == "--server" && i + 1 < args.Length)
 134              return args[i + 1];
 135          if (args[i].StartsWith("--server=", StringComparison.Ordinal))
 136              return args[i]["--server=".Length..];
 137      }
 138  
 139      // 2. Check environment variable
 140      if (!string.IsNullOrWhiteSpace(envAddress))
 141          return envAddress;
 142  
 143      // 3. mDNS LAN discovery
 144      Console.Write("Scanning LAN for GUNRPG servers");
 145      using var spinnerCts = new CancellationTokenSource();
 146      var spinnerTask = Task.Run(async () =>
 147      {
 148          var frames = new[] { "|", "/", "-", "\\" };
 149          var i = 0;
 150          while (!spinnerCts.IsCancellationRequested)
 151          {
 152              Console.Write($"\rScanning LAN for GUNRPG servers... {frames[i++ % frames.Length]}");
 153              try { await Task.Delay(100, spinnerCts.Token); }
 154              catch (OperationCanceledException) { break; }
 155          }
 156      }, spinnerCts.Token);
 157  
 158      List<LanDiscoveryService.DiscoveredServer> servers;
 159      try
 160      {
 161          servers = LanDiscoveryService.DiscoverAsync(TimeSpan.FromSeconds(3)).GetAwaiter().GetResult();
 162      }
 163      catch
 164      {
 165          servers = [];
 166      }
 167  
 168      spinnerCts.Cancel();
 169      try { spinnerTask.GetAwaiter().GetResult(); } catch { /* spinner errors are non-fatal */ }
 170      Console.WriteLine();
 171  
 172      if (servers.Count > 0)
 173      {
 174          // Ping each discovered server concurrently using a single client.
 175          // Local server takes priority; among reachable servers, the lowest latency wins.
 176          using var pingClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
 177          var machineName = System.Net.Dns.GetHostName();
 178          var pingResults = Task.WhenAll(servers.Select(async s =>
 179          {
 180              var ms = await MeasurePingMsAsync(pingClient, s.Scheme, s.Hostname, s.Port);
 181              return (Server: s, PingMs: ms);
 182          })).GetAwaiter().GetResult();
 183  
 184          var best = pingResults
 185              .Where(p => p.PingMs < long.MaxValue)
 186              .OrderBy(p => IsLocalServer(p.Server.Hostname, machineName) ? 0 : 1)
 187              .ThenBy(p => p.PingMs)
 188              .Select(p => p.Server)
 189              .FirstOrDefault();
 190  
 191          if (best != null)
 192          {
 193              var url = $"{best.Scheme}://{best.Hostname}:{best.Port}";
 194              if (best.Version != null && best.Version != clientVersion)
 195                  Console.WriteLine($"  ⚠ Version mismatch: server={best.Version}, client={clientVersion}");
 196              Console.WriteLine($"Connecting to {best.DisplayName} at {url}...");
 197              return url;
 198          }
 199  
 200          Console.WriteLine("GUNRPG servers were discovered on the LAN but none were reachable. Starting in offline mode.");
 201          return "http://localhost:5209";
 202      }
 203  
 204      Console.WriteLine("No GUNRPG servers found on LAN. Starting in offline mode.");
 205      return "http://localhost:5209";
 206  }
 207  
 208  /// <summary>
 209  /// Returns the round-trip time in milliseconds to reach the given server, or <see cref="long.MaxValue"/> if unreachable.
 210  /// </summary>
 211  static async Task<long> MeasurePingMsAsync(HttpClient client, string scheme, string hostname, int port)
 212  {
 213      var sw = System.Diagnostics.Stopwatch.StartNew();
 214      try
 215      {
 216          using var response = await client.GetAsync(
 217              $"{scheme}://{hostname}:{port}/",
 218              HttpCompletionOption.ResponseHeadersRead);
 219          sw.Stop();
 220          return sw.ElapsedMilliseconds;
 221      }
 222      catch
 223      {
 224          return long.MaxValue;
 225      }
 226  }
 227  
 228  /// <summary>
 229  /// Returns <see langword="true"/> if the hostname refers to the current machine.
 230  /// </summary>
 231  static bool IsLocalServer(string hostname, string machineName)
 232  {
 233      if (hostname.Equals("localhost", StringComparison.OrdinalIgnoreCase)
 234          || hostname == "127.0.0.1"
 235          || hostname == "::1")
 236          return true;
 237  
 238      return hostname.Equals(machineName, StringComparison.OrdinalIgnoreCase)
 239          || hostname.Equals(machineName + ".local", StringComparison.OrdinalIgnoreCase);
 240  }
 241  
 242  class GameState(HttpClient client, JsonSerializerOptions options, IGameBackend backend, GameBackendResolver backendResolver, OfflineStore? offlineStore = null, LiteDB.LiteDatabase? offlineDb = null, SessionManager? sessionManager = null, Screen initialScreen = Screen.MainMenu)
 243  {
 244      public Screen CurrentScreen { get; set; } = initialScreen;
 245      public Screen ReturnScreen { get; set; } = Screen.MainMenu;
 246      
 247      public Guid? CurrentOperatorId { get; set; }
 248      public OperatorState? CurrentOperator { get; set; }
 249      public Guid? ActiveSessionId { get; set; }
 250      public CombatSessionDto? CurrentSession { get; set; }
 251      public List<OperatorSummary>? AvailableOperators { get; set; }
 252      
 253      public string OperatorName { get; set; } = "";
 254      public string? Message { get; set; }
 255      public string? ErrorMessage { get; set; }
 256      
 257      private IGameBackend _backend = backend;
 258  
 259      // Intent selection state
 260      public string SelectedPrimary { get; set; } = "None";
 261      public string SelectedMovement { get; set; } = "Stand";
 262      public string SelectedStance { get; set; } = "None";
 263      public string SelectedCover { get; set; } = "None";
 264      public string SelectedCategory { get; set; } = IntentCategory.Primary;
 265  
 266      private static class IntentCategory
 267      {
 268          public const string Primary = "PRIMARY";
 269          public const string Movement = "MOVEMENT";
 270          public const string Stance = "STANCE";
 271          public const string Cover = "COVER";
 272      }
 273      // Disk-persisted combat service for offline play (uses same LiteDB file as operator snapshots)
 274      private CombatSessionService? _localCombatService;
 275      private bool _usingLocalCombat;
 276      private int _activeOfflineMissionSeed;
 277      private readonly IDeterministicCombatEngine _deterministicEngine = new DeterministicCombatEngine();
 278  
 279      private DateTime? _raidStartTimeUtc;
 280      private static readonly TimeSpan RaidDuration = TimeSpan.FromMinutes(30);
 281      private RaidState _raidState = RaidState.Completed;
 282      private bool _exfilFailureRequested;
 283      private readonly object _raidStateLock = new();
 284  
 285      // Background SSE stream for real-time operator state updates
 286      private CancellationTokenSource? _streamCts;
 287      private CancellationToken _appCt;
 288      private Task? _sseTask;
 289  
 290      // Background SSE stream for real-time combat session state updates
 291      private CancellationTokenSource? _sessionStreamCts;
 292      private Task? _sessionSseTask;
 293      private Guid? _streamingSessionId;
 294  
 295      /// <summary>
 296      /// Starts a background SSE subscription for real-time operator updates from the server.
 297      /// When the server appends a new operator event (from this or any other connected client),
 298      /// the SSE stream delivers a notification and the client re-fetches the full operator state.
 299      /// Only active in online mode; no-ops otherwise.
 300      /// </summary>
 301      public void StartOperatorStream(Guid operatorId, CancellationToken appCt)
 302      {
 303          if (_backend is not OnlineGameBackend) return;
 304  
 305          // Cancel any previous stream subscription
 306          _streamCts?.Cancel();
 307          _streamCts?.Dispose();
 308          _streamCts = CancellationTokenSource.CreateLinkedTokenSource(appCt);
 309          var ct = _streamCts.Token;
 310  
 311          _sseTask = Task.Run(async () =>
 312          {
 313              try
 314              {
 315                  using var response = await client.GetAsync(
 316                      $"operators/{operatorId}/stream",
 317                      HttpCompletionOption.ResponseHeadersRead,
 318                      ct);
 319  
 320                  if (!response.IsSuccessStatusCode) return;
 321  
 322                  using var stream = await response.Content.ReadAsStreamAsync(ct);
 323                  using var reader = new System.IO.StreamReader(stream);
 324  
 325                  while (!ct.IsCancellationRequested)
 326                  {
 327                      var line = await reader.ReadLineAsync(ct);
 328                      if (line == null) break; // server closed the connection
 329  
 330                      if (!line.StartsWith("data: ", StringComparison.Ordinal)) continue;
 331  
 332                      // Refresh operator state on any event notification
 333                      if (CurrentOperatorId != operatorId) break;
 334  
 335                      try
 336                      {
 337                          using var refreshResponse = await client.GetAsync(
 338                              $"operators/{operatorId}", ct);
 339                          if (!refreshResponse.IsSuccessStatusCode) continue;
 340  
 341                          var json = await refreshResponse.Content
 342                              .ReadAsStringAsync(ct);
 343                          using var doc = System.Text.Json.JsonDocument.Parse(json);
 344                          var updatedOp = ParseOperator(doc.RootElement);
 345                          if (updatedOp != null)
 346                          {
 347                              CurrentOperator = updatedOp;
 348                              SyncRaidStateFromOperator();
 349                              ApplyPendingRaidStateTransitions();
 350  
 351                              // Cross-client screen synchronization: update screen to match operator state.
 352                              if (updatedOp.CurrentMode == "Infil")
 353                              {
 354                                  if (doc.RootElement.TryGetProperty("activeCombatSession", out var sJson) &&
 355                                      sJson.ValueKind != JsonValueKind.Null)
 356                                  {
 357                                      // Another client started a combat session — transition to combat screen.
 358                                      var session = ParseSession(sJson, options);
 359                                      if (session != null)
 360                                      {
 361                                          CurrentSession = session;
 362                                          ActiveSessionId = session.Id;
 363                                          if (CurrentScreen != Screen.CombatSession)
 364                                              CurrentScreen = Screen.CombatSession;
 365                                      }
 366                                  }
 367                                  else
 368                                  {
 369                                      // Another client resolved or abandoned the active combat and returned the
 370                                      // operator to the field. Clear the local combat view so the console does not
 371                                      // stay stuck on a dead or completed encounter.
 372                                      ActiveSessionId = null;
 373                                      CurrentSession = null;
 374  
 375                                      if (CurrentScreen != Screen.BaseCamp &&
 376                                          CurrentScreen != Screen.StartMission)
 377                                      {
 378                                          // Operator entered or returned to Infil mode from another client — show field ops.
 379                                          CurrentScreen = Screen.BaseCamp;
 380                                      }
 381                                  }
 382                              }
 383                              else if (updatedOp.CurrentMode == "Base" &&
 384                                       CurrentScreen is Screen.CombatSession or Screen.StartMission)
 385                              {
 386                                  // Operator returned to Base (e.g., exfil from another client).
 387                                  ActiveSessionId = null;
 388                                  CurrentSession = null;
 389                                  CurrentScreen = Screen.BaseCamp;
 390                              }
 391                          }
 392                      }
 393                      catch (Exception)
 394                      {
 395                          // Best-effort: ignore transient refresh errors
 396                      }
 397                  }
 398              }
 399              catch (OperationCanceledException) { }
 400              catch (Exception)
 401              {
 402                  // Non-fatal: SSE stream closed or server unavailable
 403              }
 404          }, ct);
 405      }
 406  
 407      /// <summary>
 408      /// Starts a background SSE subscription for real-time combat session state updates.
 409      /// When the server saves updated session state (from this or any other connected client),
 410      /// the SSE stream delivers a notification and the client re-fetches the full session state.
 411      /// Only active in online mode when not using local combat; no-ops otherwise.
 412      /// </summary>
 413      public void StartCombatSessionStream(Guid sessionId, CancellationToken appCt)
 414      {
 415          if (_backend is not OnlineGameBackend) return;
 416  
 417          // Cancel any previous combat session stream
 418          _sessionStreamCts?.Cancel();
 419          _sessionStreamCts?.Dispose();
 420          _sessionStreamCts = CancellationTokenSource.CreateLinkedTokenSource(appCt);
 421          _streamingSessionId = sessionId;
 422          var ct = _sessionStreamCts.Token;
 423  
 424          _sessionSseTask = Task.Run(async () =>
 425          {
 426              try
 427              {
 428                  using var response = await client.GetAsync(
 429                      $"sessions/{sessionId}/stream",
 430                      HttpCompletionOption.ResponseHeadersRead,
 431                      ct);
 432  
 433                  if (!response.IsSuccessStatusCode) return;
 434  
 435                  using var stream = await response.Content.ReadAsStreamAsync(ct);
 436                  using var reader = new System.IO.StreamReader(stream);
 437  
 438                  while (!ct.IsCancellationRequested)
 439                  {
 440                      var line = await reader.ReadLineAsync(ct);
 441                      if (line == null) break; // server closed the connection
 442  
 443                      if (!line.StartsWith("data: ", StringComparison.Ordinal)) continue;
 444  
 445                      // Only refresh if we are still watching the same session
 446                      if (ActiveSessionId != sessionId) break;
 447  
 448                      try
 449                      {
 450                          using var refreshResponse = await client.GetAsync(
 451                              $"sessions/{sessionId}/state", ct);
 452                          if (!refreshResponse.IsSuccessStatusCode) continue;
 453  
 454                          var sessionData = await refreshResponse.Content
 455                              .ReadFromJsonAsync<CombatSessionDto>(options, ct);
 456                          if (sessionData != null)
 457                          {
 458                              CurrentSession = sessionData;
 459  
 460                              // If the session was concluded by another client (e.g. web client advanced
 461                              // combat to victory/defeat), navigate away from the combat screen so the
 462                              // console user is not left stuck on a stale combat view.
 463                              // Guard is intentionally absent so a completion event received while
 464                              // momentarily on a sub-screen (e.g. Screen.Message) is not missed.
 465                              if (sessionData.IsConcluded)
 466                              {
 467                                  if (sessionData.IsVictory)
 468                                  {
 469                                      Message = "MISSION SUCCESS\n\nTarget eliminated. Exfil to secure your XP.\n\nPress OK to continue.";
 470                                  }
 471                                  else if (sessionData.Player.IsAlive)
 472                                  {
 473                                      Message = "MISSION COMPLETE\n\nYou survived, but the target was not eliminated.\n\nPress OK to continue.";
 474                                  }
 475                                  else
 476                                  {
 477                                      Message = "MISSION FAILED\n\nYou were eliminated.\n\nPress OK to continue.";
 478                                  }
 479                                  ActiveSessionId = null;
 480                                  CurrentSession = null;
 481                                  CurrentScreen = Screen.MissionComplete;
 482                              }
 483                          }
 484                      }
 485                      catch (Exception)
 486                      {
 487                          // Best-effort: ignore transient refresh errors
 488                      }
 489                  }
 490              }
 491              catch (OperationCanceledException) { }
 492              catch (Exception)
 493              {
 494                  // Non-fatal: SSE stream closed or server unavailable
 495              }
 496              finally
 497              {
 498                  // If the SSE stream loop exits without an explicit cancellation,
 499                  // allow the UI to restart streaming for the current session.
 500                  if (!ct.IsCancellationRequested && ActiveSessionId == sessionId)
 501                  {
 502                      _sessionStreamCts = null;
 503                      _streamingSessionId = null;
 504                  }
 505              }
 506          }, ct);
 507      }
 508  
 509      /// <summary>
 510      /// Stops the background combat session SSE stream, if one is running.
 511      /// Called when leaving the CombatSession screen.
 512      /// </summary>
 513      public void StopCombatSessionStream()
 514      {
 515          _sessionStreamCts?.Cancel();
 516          _sessionStreamCts?.Dispose();
 517          _sessionStreamCts = null;
 518          _sessionSseTask = null;
 519          _streamingSessionId = null;
 520      }
 521  
 522      public Task<Hex1bWidget> BuildUI(RootContext ctx, CancellationTokenSource cts)
 523      {
 524          _appCt = cts.Token;
 525  
 526          // Auth state machine: auto-transition screens based on SessionManager state.
 527          if (sessionManager is not null)
 528          {
 529              var authState = sessionManager.State;
 530              if (authState == AuthState.Authenticating)
 531              {
 532                  // Always show the Authenticating screen while the device flow is in progress.
 533                  CurrentScreen = Screen.Authenticating;
 534              }
 535              else if (authState == AuthState.Authenticated && CurrentScreen == Screen.Authenticating)
 536              {
 537                  // Login succeeded — move to the main menu and load the last used operator.
 538                  CurrentScreen = Screen.MainMenu;
 539                  LoadSavedOperatorId();
 540              }
 541              else if (authState == AuthState.NotAuthenticated && CurrentScreen == Screen.Authenticating)
 542              {
 543                  // Device-code flow failed or was cancelled — return to LoginMenu so the
 544                  // user can see the error message and retry.
 545                  CurrentScreen = Screen.LoginMenu;
 546              }
 547          }
 548  
 549          SyncRaidStateFromOperator();
 550          ApplyPendingRaidStateTransitions();
 551  
 552          // Start the SSE stream on first render if an operator was auto-loaded at startup
 553          if (CurrentOperatorId.HasValue && _streamCts == null)
 554          {
 555              StartOperatorStream(CurrentOperatorId.Value, _appCt);
 556          }
 557  
 558          // Manage combat session SSE stream: start when on CombatSession screen (online, not using local combat),
 559          // stop when leaving the screen or switching sessions.
 560          if (CurrentScreen == Screen.CombatSession && !_usingLocalCombat && ActiveSessionId.HasValue)
 561          {
 562              if (_sessionStreamCts == null || _streamingSessionId != ActiveSessionId)
 563              {
 564                  StartCombatSessionStream(ActiveSessionId.Value, _appCt);
 565              }
 566          }
 567          else if (_sessionStreamCts != null)
 568          {
 569              StopCombatSessionStream();
 570          }
 571  
 572          var widget = CurrentScreen switch
 573          {
 574              Screen.LoginMenu => BuildLoginMenu(cts),
 575              Screen.Authenticating => BuildAuthenticating(),
 576              Screen.MainMenu => BuildMainMenu(cts),
 577              Screen.SelectOperator => BuildSelectOperator(),
 578              Screen.CreateOperator => BuildCreateOperator(),
 579              Screen.BaseCamp => BuildBaseCamp(),
 580              Screen.StartMission => BuildStartMission(),
 581              Screen.CombatSession => BuildCombatSession(),
 582              Screen.ExfilFailed => BuildExfilFailed(),
 583              Screen.MissionComplete => BuildMissionComplete(),
 584              Screen.Message => BuildMessage(),
 585              Screen.ChangeLoadout => BuildChangeLoadout(),
 586              Screen.TreatWounds => BuildTreatWounds(),
 587              Screen.UnlockPerk => BuildUnlockPerk(),
 588              Screen.AbortMission => BuildAbortMission(),
 589              Screen.PetActions => BuildPetActions(),
 590              _ => new TextBlockWidget("Unknown screen")
 591          };
 592  
 593          return Task.FromResult(widget);
 594      }
 595  
 596      public void Tick()
 597      {
 598          lock (_raidStateLock)
 599          {
 600              if (_raidState is not (RaidState.Infil or RaidState.Combat))
 601                  return;
 602  
 603              var remaining = GetRemainingRaidTimeCore();
 604              if (remaining.HasValue && remaining.Value <= TimeSpan.Zero)
 605              {
 606                  _exfilFailureRequested = true;
 607              }
 608          }
 609      }
 610  
 611      private bool TryEnsureOnline()
 612      {
 613          if (backendResolver.CurrentMode == GameMode.Online)
 614              return true;
 615  
 616          try
 617          {
 618              _backend = backendResolver.ResolveAsync().GetAwaiter().GetResult();
 619              return backendResolver.CurrentMode == GameMode.Online;
 620          }
 621          catch (Exception ex)
 622          {
 623              ErrorMessage = $"Connection failed: {ex.Message}";
 624              return false;
 625          }
 626      }
 627  
 628      Hex1bWidget BuildMainMenu(CancellationTokenSource cts)
 629      {
 630          var mode = backendResolver.CurrentMode;
 631          var modeLabel = mode switch
 632          {
 633              GameMode.Online => "[ONLINE]",
 634              GameMode.Offline => "[OFFLINE]",
 635              GameMode.Blocked => "[OFFLINE - NO OPERATOR]",
 636              _ => "[UNKNOWN]"
 637          };
 638  
 639          // Build the menu items depending on whether a session manager is present.
 640          // With session manager (online): Operators, Missions, Logout, Quit.
 641          // Without (offline/legacy): original Create + Select + Exit layout.
 642          var hasAuth = sessionManager is not null;
 643          var menuItems = hasAuth
 644              ? new List<string> { "OPERATORS", "MISSIONS", "LOGOUT", "QUIT" }
 645              : new List<string> { "CREATE NEW OPERATOR", "SELECT OPERATOR", "EXIT" };
 646  
 647          return new VStackWidget([
 648              UI.CreateBorder($"GUNRPG - OPERATOR TERMINAL {modeLabel}"),
 649              new TextBlockWidget(""),
 650              mode == GameMode.Blocked
 651                  ? new TextBlockWidget("  ⚠ Server unreachable and no infiled operator. Gameplay blocked.")
 652                  : new TextBlockWidget(""),
 653              UI.CreateBorder("MAIN MENU", new VStackWidget([
 654                  new TextBlockWidget("  Select an option:"),
 655                  new TextBlockWidget(""),
 656                  new ListWidget(menuItems.ToArray()).OnItemActivated(e => {
 657                      var selected = menuItems[e.ActivatedIndex];
 658                      switch (selected)
 659                      {
 660                          case "OPERATORS":
 661                          case "SELECT OPERATOR":
 662                              if (mode == GameMode.Blocked && !TryEnsureOnline())
 663                              {
 664                                  ErrorMessage = "Cannot select operators while server is unreachable.";
 665                                  Message = "Server connection required to load operator list.\n\nPress OK to continue.";
 666                                  CurrentScreen = Screen.Message;
 667                                  ReturnScreen = Screen.MainMenu;
 668                              }
 669                              else
 670                              {
 671                                  ErrorMessage = null;
 672                                  LoadOperatorList();
 673                                  CurrentScreen = Screen.SelectOperator;
 674                              }
 675                              break;
 676                          case "MISSIONS":
 677                              if (CurrentOperator is not null)
 678                              {
 679                                  CurrentScreen = Screen.BaseCamp;
 680                              }
 681                              else if (mode == GameMode.Blocked && !TryEnsureOnline())
 682                              {
 683                                  ErrorMessage = "Cannot access missions while server is unreachable.";
 684                                  Message = "Server connection required to access missions.\n\nPress OK to continue.";
 685                                  CurrentScreen = Screen.Message;
 686                                  ReturnScreen = Screen.MainMenu;
 687                              }
 688                              else
 689                              {
 690                                  // No operator selected — redirect to operator selection first.
 691                                  ErrorMessage = null;
 692                                  LoadOperatorList();
 693                                  CurrentScreen = Screen.SelectOperator;
 694                              }
 695                              break;
 696                          case "CREATE NEW OPERATOR":
 697                              if (!TryEnsureOnline())
 698                              {
 699                                  ErrorMessage = "Cannot create operators while offline.";
 700                                  Message = "Operator creation requires a server connection.\n\nPress OK to continue.";
 701                                  CurrentScreen = Screen.Message;
 702                                  ReturnScreen = Screen.MainMenu;
 703                              }
 704                              else
 705                              {
 706                                  CurrentScreen = Screen.CreateOperator;
 707                                  OperatorName = "";
 708                              }
 709                              break;
 710                          case "LOGOUT":
 711                              sessionManager?.Logout();
 712                              // Clear local operator state so it isn't shown for the next user.
 713                              ClearOperatorState();
 714                              CurrentScreen = Screen.LoginMenu;
 715                              break;
 716                          case "EXIT":
 717                          case "QUIT":
 718                              cts.Cancel();
 719                              break;
 720                      }
 721                  })
 722              ])),
 723              new TextBlockWidget(""),
 724              UI.CreateStatusBar($"API: {client.BaseAddress} | Mode: {modeLabel}"),
 725          ]);
 726      }
 727  
 728      /// <summary>
 729      /// Login menu shown when the user is not authenticated.
 730      /// Offers Login (starts the device-code flow) and Quit.
 731      /// </summary>
 732      Hex1bWidget BuildLoginMenu(CancellationTokenSource cts)
 733      {
 734          var mode = backendResolver.CurrentMode;
 735          var modeLabel = mode switch
 736          {
 737              GameMode.Online => "[ONLINE]",
 738              GameMode.Offline => "[OFFLINE]",
 739              GameMode.Blocked => "[OFFLINE - NO OPERATOR]",
 740              _ => "[UNKNOWN]"
 741          };
 742  
 743          var menuItems = new[] { "LOGIN", "QUIT" };
 744  
 745          var contentWidgets = new List<Hex1bWidget>
 746          {
 747              new TextBlockWidget("  Authentication required to access GUNRPG."),
 748              new TextBlockWidget("  Use a passkey or YubiKey in your browser to log in."),
 749              new TextBlockWidget(""),
 750          };
 751  
 752          if (sessionManager?.LoginError is { } loginError)
 753          {
 754              contentWidgets.Add(new TextBlockWidget($"  ⚠ Login failed: {loginError}"));
 755              contentWidgets.Add(new TextBlockWidget(""));
 756          }
 757  
 758          contentWidgets.Add(new ListWidget(menuItems).OnItemActivated(e =>
 759          {
 760              switch (e.ActivatedIndex)
 761              {
 762                  case 0: // LOGIN
 763                      sessionManager?.StartLogin(cts.Token);
 764                      break;
 765                  case 1: // QUIT
 766                      cts.Cancel();
 767                      break;
 768              }
 769          }));
 770  
 771          return new VStackWidget([
 772              UI.CreateBorder($"GUNRPG - OPERATOR TERMINAL {modeLabel}"),
 773              new TextBlockWidget(""),
 774              UI.CreateBorder("AUTHENTICATION", new VStackWidget(contentWidgets)),
 775              new TextBlockWidget(""),
 776              UI.CreateStatusBar($"API: {client.BaseAddress} | Mode: {modeLabel}"),
 777          ]);
 778      }
 779  
 780      /// <summary>
 781      /// Shown while the device-code flow is in progress.
 782      /// Displays the verification URL and user code for the user to enter in their browser.
 783      /// Automatically transitions to the main menu once the server reports success.
 784      /// </summary>
 785      Hex1bWidget BuildAuthenticating()
 786      {
 787          var url = sessionManager?.VerificationUrl ?? "Waiting for server…";
 788          var code = sessionManager?.UserCode ?? "…";
 789  
 790          return new VStackWidget([
 791              UI.CreateBorder("GUNRPG - AUTHENTICATING"),
 792              new TextBlockWidget(""),
 793              UI.CreateBorder("DEVICE CODE LOGIN", new VStackWidget([
 794                  new TextBlockWidget("  Open the following URL in your browser to authenticate:"),
 795                  new TextBlockWidget(""),
 796                  new TextBlockWidget($"  {url}"),
 797                  new TextBlockWidget(""),
 798                  new TextBlockWidget($"  Enter code: {code}"),
 799                  new TextBlockWidget(""),
 800                  new TextBlockWidget("  Waiting for browser authorization…"),
 801              ])),
 802              new TextBlockWidget(""),
 803              UI.CreateStatusBar("Complete authentication in your browser to continue"),
 804          ]);
 805      }
 806  
 807      void LoadOperatorList()
 808      {
 809          try
 810          {
 811              var response = client.GetAsync("operators").GetAwaiter().GetResult();
 812              if (response.IsSuccessStatusCode)
 813              {
 814                  var operators = response.Content.ReadFromJsonAsync<List<JsonElement>>(options).GetAwaiter().GetResult();
 815                  AvailableOperators = operators?.Select(ParseOperatorSummary).ToList();
 816              }
 817              else
 818              {
 819                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
 820                  ErrorMessage = $"{response.StatusCode}: {errorContent}";
 821                  AvailableOperators = new List<OperatorSummary>();
 822              }
 823          }
 824          catch (Exception ex)
 825          {
 826              ErrorMessage = $"Exception: {ex.Message}";
 827              AvailableOperators = new List<OperatorSummary>();
 828          }
 829      }
 830  
 831      /// <summary>
 832      /// Clears all in-memory operator and combat session state.
 833      /// Called on logout so stale data is not visible when a different user logs in.
 834      /// Also cancels any active SSE streams so they don't continue sending authenticated
 835      /// requests for the logged-out user's operator.
 836      /// </summary>
 837      void ClearOperatorState()
 838      {
 839          // Cancel active SSE streams before clearing the state they refer to.
 840          StopCombatSessionStream();
 841          _streamCts?.Cancel();
 842          _streamCts?.Dispose();
 843          _streamCts = null;
 844          _sseTask = null;
 845  
 846          CurrentOperatorId = null;
 847          CurrentOperator = null;
 848          ActiveSessionId = null;
 849          CurrentSession = null;
 850          AvailableOperators = null;
 851      }
 852  
 853      Hex1bWidget BuildSelectOperator()
 854      {
 855          var contentWidgets = new List<Hex1bWidget>
 856          {
 857              new TextBlockWidget("  Available Operators:"),
 858              new TextBlockWidget("")
 859          };
 860  
 861          if (ErrorMessage != null)
 862          {
 863              contentWidgets.Add(new TextBlockWidget($"  ERROR: {ErrorMessage}"));
 864              contentWidgets.Add(new TextBlockWidget(""));
 865          }
 866  
 867          if (AvailableOperators == null || AvailableOperators.Count == 0)
 868          {
 869              contentWidgets.Add(new TextBlockWidget("  No operators found."));
 870              contentWidgets.Add(new TextBlockWidget("  Create one from the main menu."));
 871              contentWidgets.Add(new TextBlockWidget(""));
 872              contentWidgets.Add(new ListWidget(new[] { "BACK TO MAIN MENU" })
 873                  .OnItemActivated(_ => CurrentScreen = Screen.MainMenu));
 874          }
 875          else
 876          {
 877              var operatorItems = AvailableOperators.Select(op => {
 878                  var status = op.IsDead ? "KIA" : op.CurrentMode;
 879                  var healthPct = op.MaxHealth > 0 ? (int)(100 * op.CurrentHealth / op.MaxHealth) : 0;
 880                  return $"{op.Name} - {status} (HP: {healthPct}%, XP: {op.TotalXp})";
 881              }).Concat(new[] { "--- BACK TO MAIN MENU ---" }).ToArray();
 882  
 883              contentWidgets.Add(new ListWidget(operatorItems).OnItemActivated(e => {
 884                  if (e.ActivatedIndex == AvailableOperators.Count)
 885                  {
 886                      // Back to main menu
 887                      CurrentScreen = Screen.MainMenu;
 888                  }
 889                  else
 890                  {
 891                      SelectOperator(AvailableOperators[e.ActivatedIndex].Id);
 892                  }
 893              }));
 894          }
 895  
 896          return new VStackWidget([
 897              UI.CreateBorder("SELECT OPERATOR"),
 898              new TextBlockWidget(""),
 899              UI.CreateBorder("OPERATOR LIST", new VStackWidget(contentWidgets)),
 900              UI.CreateStatusBar("Choose an operator to continue")
 901          ]);
 902      }
 903  
 904      void SelectOperator(Guid operatorId)
 905      {
 906          try
 907          {
 908              CurrentOperatorId = operatorId;
 909              SaveCurrentOperatorId();
 910              LoadOperator(operatorId);
 911              StartOperatorStream(operatorId, _appCt);
 912              // LoadOperator may set screen to CombatSession if auto-resuming, so only set BaseCamp if not already set
 913              if (CurrentScreen != Screen.CombatSession)
 914              {
 915                  CurrentScreen = Screen.BaseCamp;
 916              }
 917          }
 918          catch (Exception ex)
 919          {
 920              ErrorMessage = ex.Message;
 921              Message = $"Failed to load operator.\nError: {ex.Message}\n\nPress OK to continue.";
 922              CurrentScreen = Screen.Message;
 923              ReturnScreen = Screen.SelectOperator;
 924          }
 925      }
 926  
 927      void LoadOperator(Guid operatorId)
 928      {
 929          // First, cleanup any completed sessions to prevent stuck state
 930          try
 931          {
 932              using var cleanupResponse = client.PostAsync($"operators/{operatorId}/cleanup", null).GetAwaiter().GetResult();
 933              // Cleanup failures are non-fatal; the Get operation will handle dangling references
 934          }
 935          catch
 936          {
 937              // Silently ignore cleanup errors; Get will still work
 938          }
 939  
 940          using var response = client.GetAsync($"operators/{operatorId}").GetAwaiter().GetResult();
 941          if (!response.IsSuccessStatusCode)
 942          {
 943              throw new Exception($"Failed to load operator: {response.StatusCode}");
 944          }
 945  
 946          var operatorDto = response.Content.ReadFromJsonAsync<JsonElement>(options).GetAwaiter().GetResult();
 947          CurrentOperator = ParseOperator(operatorDto);
 948          
 949          // Auto-resume combat if operator has active session and is still in Infil mode
 950          if (CurrentOperator?.CurrentMode == "Infil" &&
 951              operatorDto.TryGetProperty("activeCombatSession", out var sessionJson) && 
 952              sessionJson.ValueKind != JsonValueKind.Null)
 953          {
 954              CurrentSession = ParseSession(sessionJson, options);
 955              ActiveSessionId = CurrentSession?.Id;
 956              CurrentScreen = Screen.CombatSession;
 957          }
 958      }
 959  
 960      void SaveCurrentOperatorId()
 961      {
 962          try
 963          {
 964              var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 965              var configDir = Path.Combine(homeDir, ".gunrpg");
 966              Directory.CreateDirectory(configDir);
 967              var configFile = Path.Combine(configDir, "current_operator.txt");
 968              File.WriteAllText(configFile, CurrentOperatorId?.ToString() ?? "");
 969          }
 970          catch
 971          {
 972              // Silently fail - not critical
 973          }
 974      }
 975  
 976      public void LoadSavedOperatorId()
 977      {
 978          try
 979          {
 980              var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 981              var configFile = Path.Combine(homeDir, ".gunrpg", "current_operator.txt");
 982              if (File.Exists(configFile))
 983              {
 984                  var idText = File.ReadAllText(configFile).Trim();
 985                  if (Guid.TryParse(idText, out var operatorId))
 986                  {
 987                      CurrentOperatorId = operatorId;
 988                      // Use IGameBackend — works for both online (HTTP) and offline (LiteDB snapshot)
 989                      var dto = _backend.GetOperatorAsync(operatorId.ToString()).GetAwaiter().GetResult();
 990                      if (dto != null)
 991                      {
 992                          CurrentOperator = OperatorStateFromDto(dto);
 993                          // If operator is in Infil mode with an active combat session, resume it —
 994                          // but only when online, as LoadSession() requires HTTP access.
 995                          if (dto.ActiveCombatSessionId.HasValue && dto.CurrentMode == "Infil"
 996                              && _backend is OnlineGameBackend)
 997                          {
 998                              ActiveSessionId = dto.ActiveCombatSessionId;
 999                              LoadSession();
1000                          }
1001                          if (CurrentScreen != Screen.CombatSession)
1002                          {
1003                              CurrentScreen = Screen.BaseCamp;
1004                          }
1005                      }
1006                      else
1007                      {
1008                          // No snapshot found (offline) or operator not found (online); clear stale ID
1009                          CurrentOperatorId = null;
1010                          SaveCurrentOperatorId();
1011                      }
1012                  }
1013              }
1014          }
1015          catch
1016          {
1017              // Silently fail - not critical
1018          }
1019      }
1020  
1021      Hex1bWidget BuildCreateOperator()
1022      {
1023          var contentWidgets = new List<Hex1bWidget>
1024          {
1025              new TextBlockWidget("  Enter operator name:"),
1026              new TextBlockWidget(""),
1027          };
1028  
1029          if (ErrorMessage != null)
1030          {
1031              contentWidgets.Add(new TextBlockWidget($"  ERROR: {ErrorMessage}"));
1032              contentWidgets.Add(new TextBlockWidget(""));
1033          }
1034  
1035          // Add text input using TextBoxWidget with OnTextChanged event handler
1036          var textBox = new TextBoxWidget(OperatorName ?? "")
1037              .OnTextChanged(args => {
1038                  OperatorName = args.NewText;
1039                  ErrorMessage = null; // Clear error when user types
1040              });
1041          contentWidgets.Add(textBox);
1042          contentWidgets.Add(new TextBlockWidget(""));
1043          contentWidgets.Add(new TextBlockWidget($"  Current: {(string.IsNullOrWhiteSpace(OperatorName) ? "(empty)" : OperatorName)}"));
1044          contentWidgets.Add(new TextBlockWidget(""));
1045          
1046          // Action menu using ListWidget
1047          var actionItems = new[] {
1048              "GENERATE RANDOM NAME",
1049              "CREATE",
1050              "BACK"
1051          };
1052          
1053          contentWidgets.Add(new ListWidget(actionItems).OnItemActivated(e => {
1054              switch (e.ActivatedIndex)
1055              {
1056                  case 0: // Generate Random Name
1057                      OperatorName = $"Operative-{Random.Shared.Next(1000, 9999)}";
1058                      break;
1059                  case 1: // Create
1060                      // NOTE: CreateOperator blocks on HTTP calls due to hex1b's synchronous event handlers.
1061                      // This is a known limitation. UI will freeze during API calls.
1062                      CreateOperator();
1063                      break;
1064                  case 2: // Back
1065                      CurrentScreen = Screen.MainMenu;
1066                      OperatorName = "";
1067                      break;
1068              }
1069          }));
1070          
1071          return new VStackWidget([
1072              UI.CreateBorder("CREATE NEW OPERATOR"),
1073              new TextBlockWidget(""),
1074              UI.CreateBorder("OPERATOR PROFILE", new VStackWidget(contentWidgets)),
1075              UI.CreateStatusBar("Type name or generate random, then CREATE")
1076          ]);
1077      }
1078  
1079      void CreateOperator()
1080      {
1081          if (!TryEnsureOnline())
1082          {
1083              ErrorMessage = "Cannot create operators while offline. Server connection required.";
1084              return;
1085          }
1086  
1087          if (string.IsNullOrWhiteSpace(OperatorName))
1088          {
1089              ErrorMessage = "Name cannot be empty";
1090              return;
1091          }
1092  
1093          try
1094          {
1095              // NOTE: Using GetAwaiter().GetResult() here because hex1b's ButtonWidget.OnClick
1096              // handlers are synchronous (Action<MouseEvent>). This is a known limitation of the
1097              // hex1b library. In a real application, consider using a different UI framework
1098              // with async event handler support.
1099              var request = new { Name = OperatorName };
1100              var response = client.PostAsJsonAsync("operators", request, options).GetAwaiter().GetResult();
1101              
1102              if (!response.IsSuccessStatusCode)
1103              {
1104                  ErrorMessage = $"Failed: {response.StatusCode}";
1105                  return;
1106              }
1107  
1108              var operatorDto = response.Content.ReadFromJsonAsync<JsonElement>(options).GetAwaiter().GetResult();
1109              CurrentOperatorId = operatorDto.GetProperty("id").GetGuid();
1110              CurrentOperator = ParseOperator(operatorDto);
1111              SaveCurrentOperatorId();
1112              ErrorMessage = null;
1113              CurrentScreen = Screen.BaseCamp;
1114          }
1115          catch (Exception ex)
1116          {
1117              ErrorMessage = ex.Message;
1118          }
1119      }
1120  
1121      Hex1bWidget BuildBaseCamp()
1122      {
1123          const string BaseActionInfil = "INFIL";
1124          const string InfilActionEngageCombat = "ENGAGE COMBAT";
1125          const string InfilActionExfil = "EXFIL";
1126  
1127          var op = CurrentOperator;
1128          if (op == null)
1129          {
1130              return new TextBlockWidget("No operator loaded");
1131          }
1132  
1133          var menuItems = new List<string>();
1134          var hasActiveCombatSession = op.ActiveCombatSessionId.HasValue || ActiveSessionId.HasValue;
1135  
1136          if (op.CurrentMode == "Base")
1137          {
1138              menuItems.Add(BaseActionInfil);
1139              menuItems.Add("CHANGE LOADOUT");
1140              menuItems.Add("TREAT WOUNDS");
1141              menuItems.Add("UNLOCK PERK");
1142              menuItems.Add("PET ACTIONS");
1143              menuItems.Add("VIEW STATS");
1144          }
1145          else // Infil mode
1146          {
1147              // In Infil mode, always allow engaging in combat
1148              // After a victory, ActiveSessionId is cleared but operator stays in Infil mode
1149              // In this case, we need to create a new session for the next combat
1150              menuItems.Add(InfilActionEngageCombat);
1151              menuItems.Add(InfilActionExfil);
1152              menuItems.Add("VIEW STATS");
1153          }
1154  
1155          menuItems.Add("MAIN MENU");
1156  
1157          var menuWidget = new ListWidget(menuItems.ToArray()).OnItemActivated(e => {
1158              var selectedItem = menuItems[e.ActivatedIndex];
1159              if (op.CurrentMode == "Base")
1160              {
1161                  switch (selectedItem)
1162                  {
1163                      case BaseActionInfil:
1164                          CurrentScreen = Screen.StartMission;
1165                          break;
1166                      case "CHANGE LOADOUT":
1167                          CurrentScreen = Screen.ChangeLoadout;
1168                          break;
1169                      case "TREAT WOUNDS":
1170                          CurrentScreen = Screen.TreatWounds;
1171                          break;
1172                      case "UNLOCK PERK":
1173                          CurrentScreen = Screen.UnlockPerk;
1174                          break;
1175                      case "PET ACTIONS":
1176                          CurrentScreen = Screen.PetActions;
1177                          break;
1178                      case "VIEW STATS":
1179                          Message = $"Operator: {op.Name}\nXP: {op.TotalXp}\nHealth: {op.CurrentHealth:F0}/{op.MaxHealth:F0}\nWeapon: {op.EquippedWeaponName}\nPerks: {string.Join(", ", op.UnlockedPerks)}\n\nPress OK to continue.";
1180                          CurrentScreen = Screen.Message;
1181                          ReturnScreen = Screen.BaseCamp;
1182                          break;
1183                      case "MAIN MENU":
1184                          CurrentScreen = Screen.MainMenu;
1185                          break;
1186                  }
1187              }
1188              else // Infil mode
1189              {
1190                  switch (selectedItem)
1191                  {
1192                      case InfilActionEngageCombat:
1193                          // If we have an active combat session, load it
1194                          // If not, start a new combat session using the infil session
1195                          if (hasActiveCombatSession)
1196                          {
1197                              ActiveSessionId ??= op.ActiveCombatSessionId;
1198                              LoadSession();
1199                          }
1200                          else if (_backend is not OnlineGameBackend)
1201                          {
1202                              // Offline: run combat using a local in-memory session (no server required)
1203                              if (StartOfflineCombatSession())
1204                              {
1205                                  CurrentScreen = Screen.CombatSession;
1206                              }
1207                              else
1208                              {
1209                                  Message = $"Failed to start offline combat.\n\n{ErrorMessage ?? "Unknown error"}\n\nPress OK to continue.";
1210                                  CurrentScreen = Screen.Message;
1211                                  ReturnScreen = Screen.BaseCamp;
1212                              }
1213                          }
1214                          else
1215                          {
1216                              // After a victory, ActiveCombatSessionId is cleared but operator stays in Infil
1217                              // Call the new endpoint to start a fresh combat session
1218                              if (StartNewCombatSession())
1219                              {
1220                                  CurrentScreen = Screen.CombatSession;
1221                              }
1222                              else
1223                              {
1224                                  Message = "Failed to start next combat.\n\nPress OK to continue.";
1225                                  CurrentScreen = Screen.Message;
1226                                  ReturnScreen = Screen.BaseCamp;
1227                              }
1228                          }
1229                          break;
1230                      case InfilActionExfil:
1231                          CurrentScreen = Screen.AbortMission;
1232                          break;
1233                      case "VIEW STATS":
1234                          Message = $"Operator: {op.Name}\nXP: {op.TotalXp}\nHealth: {op.CurrentHealth:F0}/{op.MaxHealth:F0}\nWeapon: {op.EquippedWeaponName}\nMission In Progress\n\nPress OK to continue.";
1235                          CurrentScreen = Screen.Message;
1236                          ReturnScreen = Screen.BaseCamp;
1237                          break;
1238                      case "MAIN MENU":
1239                          CurrentScreen = Screen.MainMenu;
1240                          break;
1241                  }
1242              }
1243          });
1244  
1245          var healthBar = UI.CreateProgressBar("HP", (int)op.CurrentHealth, (int)op.MaxHealth, 30);
1246          var xpInfo = $"XP: {op.TotalXp}  EXFIL STREAK: {op.ExfilStreak}";
1247  
1248          // Create mode-specific title
1249          var modeTitle = op.CurrentMode == "Base" ? "BASE CAMP" : "FIELD OPS (INFIL)";
1250          var modeDescription = op.CurrentMode == "Base"
1251              ? "  Ready for new missions and maintenance"
1252              : "  Mission in progress - limited actions available";
1253  
1254          return new VStackWidget([
1255              UI.CreateBorder($"OPERATOR: {op.Name.ToUpper()}"),
1256              op.CurrentMode == "Infil" ? UI.CreateBorder("EXFIL TIMER", BuildExfilTimerWidget()) : new TextBlockWidget(""),
1257              new TextBlockWidget(""),
1258              new HStackWidget([
1259                  UI.CreateBorder("STATUS", new VStackWidget([
1260                      new TextBlockWidget("  "),
1261                      healthBar,
1262                      new TextBlockWidget(""),
1263                      new TextBlockWidget($"  {xpInfo}"),
1264                      new TextBlockWidget($"  WEAPON: {op.EquippedWeaponName}"),
1265                      new TextBlockWidget($"  MODE: {op.CurrentMode}"),
1266                      new TextBlockWidget($"  PERKS: {op.UnlockedPerks.Count}"),
1267                      op.IsDead ? new TextBlockWidget("  STATUS: KIA") : new TextBlockWidget("")
1268                  ])),
1269                  new TextBlockWidget("  "),
1270                  UI.CreateBorder(modeTitle, new VStackWidget([
1271                      new TextBlockWidget(modeDescription),
1272                      new TextBlockWidget(""),
1273                      menuWidget
1274                  ]))
1275              ]),
1276              new TextBlockWidget(""),
1277              UI.CreateStatusBar($"Operator ID: {op.Id}")
1278          ]);
1279      }
1280  
1281      Hex1bWidget BuildStartMission()
1282      {
1283          var menuItems = new[] {
1284              "BEGIN INFIL",
1285              "CANCEL"
1286          };
1287  
1288          return new VStackWidget([
1289              UI.CreateBorder("COMBAT BRIEFING"),
1290              new TextBlockWidget(""),
1291              UI.CreateBorder("INFIL", new VStackWidget([
1292                  new TextBlockWidget("  OBJECTIVE: Engage hostile target"),
1293                  new TextBlockWidget("  TIME LIMIT: 30 minutes"),
1294                  new TextBlockWidget("  THREAT LEVEL: Variable"),
1295                  new TextBlockWidget(""),
1296                  new TextBlockWidget("  WARNING: On death, your streak resets and you are returned to base."),
1297                  new TextBlockWidget(""),
1298                  new TextBlockWidget("  Select action:"),
1299                  new TextBlockWidget(""),
1300                  new ListWidget(menuItems).OnItemActivated(e => {
1301                      switch (e.ActivatedIndex)
1302                      {
1303                          case 0: // BEGIN INFIL
1304                              // NOTE: StartMission blocks on HTTP calls due to hex1b's synchronous event handlers.
1305                              // This is a known limitation. UI will freeze during API calls.
1306                              StartMission();
1307                              break;
1308                          case 1: // CANCEL
1309                              CurrentScreen = Screen.BaseCamp;
1310                              break;
1311                      }
1312                  })
1313              ])),
1314              UI.CreateStatusBar("Prepare for combat")
1315          ]);
1316      }
1317  
1318      void StartMission()
1319      {
1320          try
1321          {
1322              // Start the infil - this only transitions the operator to Infil mode
1323              // Combat sessions are created when the player chooses to engage in combat
1324              var response = client.PostAsync($"operators/{CurrentOperatorId}/infil/start", null).GetAwaiter().GetResult();
1325              
1326              if (!response.IsSuccessStatusCode)
1327              {
1328                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
1329                  ErrorMessage = $"Failed to infil: {response.StatusCode} - {errorContent}";
1330                  Message = $"Infil failed.\nError: {ErrorMessage}\n\nPress OK to continue.";
1331                  CurrentScreen = Screen.Message;
1332                  ReturnScreen = Screen.BaseCamp;
1333                  return;
1334              }
1335  
1336              var result = response.Content.ReadFromJsonAsync<JsonElement>(options).GetAwaiter().GetResult();
1337              // Don't set ActiveSessionId - it will be set when player engages in combat
1338              CurrentOperator = ParseOperator(result.GetProperty("operator"));
1339  
1340              // Save offline snapshot so the operator is available if the server becomes unreachable.
1341              // This is the single infil path: one server call + one local persist.
1342              // NOTE: GetAwaiter().GetResult() is required here — hex1b event handlers are synchronous.
1343              if (_backend is OnlineGameBackend onlineBackend)
1344              {
1345                  try
1346                  {
1347                      onlineBackend.InfilOperatorAsync(CurrentOperatorId!.Value.ToString()).GetAwaiter().GetResult();
1348                  }
1349                  catch (Exception snapshotEx)
1350                  {
1351                      // Snapshot save failure is non-fatal — offline play simply won't be available
1352                      Console.WriteLine($"[INFIL] Snapshot save failed (non-fatal): {snapshotEx.Message}");
1353                  }
1354              }
1355  
1356              // Return to Field Ops; player can choose to engage combat or exfil
1357              CurrentScreen = Screen.BaseCamp;
1358          }
1359          catch (Exception ex)
1360          {
1361              ErrorMessage = ex.Message;
1362              Message = $"Error: {ex.Message}\n\nPress OK to continue.";
1363              CurrentScreen = Screen.Message;
1364              ReturnScreen = Screen.BaseCamp;
1365          }
1366      }
1367  
1368      void LoadSession()
1369      {
1370          try
1371          {
1372              var sessionData = client.GetFromJsonAsync<CombatSessionDto>($"sessions/{ActiveSessionId}/state", options).GetAwaiter().GetResult();
1373              if (sessionData != null)
1374              {
1375                  CurrentSession = sessionData;
1376                  
1377                  // If session is completed, either continue infil with a fresh target or show completion screen.
1378                  if (sessionData.Phase == "Completed")
1379                  {
1380                      // In infil mode, completed combat can continue with a fresh target in the same infil session.
1381                      if (CurrentOperator?.CurrentMode == "Infil" && StartNextCombatInInfil())
1382                      {
1383                          CurrentScreen = Screen.CombatSession;
1384                      }
1385                      else
1386                      {
1387                          Message = string.IsNullOrWhiteSpace(ErrorMessage)
1388                              ? "Mission completed."
1389                              : $"Unable to start next combat session.\nError: {ErrorMessage}";
1390                          CurrentScreen = Screen.MissionComplete;
1391                      }
1392                  }
1393                  else
1394                  {
1395                      CurrentScreen = Screen.CombatSession;
1396                  }
1397              }
1398          }
1399          catch (HttpRequestException ex) when (ex.Message.Contains("404"))
1400          {
1401              // Session doesn't exist - this can happen if operator was created before session creation was implemented
1402              // Force end the infil by processing a failed outcome to reset the operator to Base mode
1403              ErrorMessage = "Combat session not found - forcing exfil";
1404              Message = $"Infil session not found in database.\n\nThis can happen with operators created before the latest updates.\nForcing exfil to reset operator state.\n\nPress OK to continue.";
1405              CurrentScreen = Screen.Message;
1406              ReturnScreen = Screen.BaseCamp;
1407              
1408              // Try to force-end the infil by processing a "died" outcome
1409              if (CurrentOperator?.Id != null)
1410              {
1411                  try
1412                  {
1413                      var request = new { SessionId = ActiveSessionId };
1414                      client.PostAsJsonAsync($"operators/{CurrentOperator.Id}/infil/outcome", request).GetAwaiter().GetResult();
1415                      LoadOperator(CurrentOperator.Id);  // Reload operator to get updated state
1416                  }
1417                  catch
1418                  {
1419                      // If that fails, just clear the local session ID
1420                      ActiveSessionId = null;
1421                  }
1422              }
1423              else
1424              {
1425                  ActiveSessionId = null;
1426              }
1427          }
1428          catch (Exception ex)
1429          {
1430              ErrorMessage = ex.Message;
1431              Message = $"Failed to load session.\nError: {ex.Message}\n\nPress OK to continue.";
1432              CurrentScreen = Screen.Message;
1433              ReturnScreen = Screen.BaseCamp;
1434          }
1435      }
1436  
1437      bool StartNextCombatInInfil()
1438      {
1439          ErrorMessage = null;
1440  
1441          if (!ActiveSessionId.HasValue || CurrentOperatorId == null || CurrentOperator == null)
1442          {
1443              return false;
1444          }
1445  
1446          try
1447          {
1448              // The server creates both the operator event and the combat session atomically,
1449              // so a single POST is sufficient — no separate /sessions call is needed.
1450              // The completed session record remains in the database for audit purposes.
1451              using var startCombatResponse = client.PostAsync($"operators/{CurrentOperatorId}/infil/combat", null).GetAwaiter().GetResult();
1452              if (!startCombatResponse.IsSuccessStatusCode)
1453              {
1454                  var errorContent = startCombatResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult();
1455                  ErrorMessage = $"Failed to start combat session: {startCombatResponse.StatusCode} - {errorContent}";
1456                  return false;
1457              }
1458  
1459              var newSessionId = startCombatResponse.Content.ReadFromJsonAsync<Guid>(options).GetAwaiter().GetResult();
1460  
1461              // Fetch the session state that the server just created
1462              var sessionState = client.GetFromJsonAsync<CombatSessionDto>($"sessions/{newSessionId}/state", options).GetAwaiter().GetResult();
1463              if (sessionState == null)
1464              {
1465                  ErrorMessage = "Failed to retrieve newly created combat session";
1466                  return false;
1467              }
1468  
1469              CurrentSession = sessionState;
1470              // Update ActiveSessionId to point to the new session
1471              ActiveSessionId = newSessionId;
1472  
1473              return true;
1474          }
1475          catch (Exception ex)
1476          {
1477              ErrorMessage = ex.Message;
1478              return false;
1479          }
1480      }
1481  
1482      /// <summary>
1483      /// Starts a new offline combat session backed by the disk-persisted LiteDB store.
1484      /// Used when the server is unreachable but the operator has an active infil snapshot.
1485      /// </summary>
1486      bool StartOfflineCombatSession()
1487      {
1488          ErrorMessage = null;
1489  
1490          if (CurrentOperator == null || CurrentOperatorId == null)
1491              return false;
1492  
1493          if (CurrentOperator.CurrentMode != "Infil")
1494          {
1495              ErrorMessage = "Cannot start combat: operator is not in Infil mode.";
1496              return false;
1497          }
1498  
1499          if (offlineDb == null)
1500          {
1501              ErrorMessage = "Offline database unavailable";
1502              return false;
1503          }
1504  
1505          try
1506          {
1507              // Lazy-initialize the local combat service (reused across combats within a session)
1508              if (_localCombatService == null)
1509              {
1510                  var localStore = new LiteDbCombatSessionStore(offlineDb);
1511                  _localCombatService = new CombatSessionService(localStore);
1512              }
1513  
1514              var request = new SessionCreateRequest
1515              {
1516                  OperatorId = CurrentOperatorId,
1517                  PlayerName = CurrentOperator.Name,
1518                  Seed = RandomNumberGenerator.GetInt32(int.MaxValue)
1519              };
1520              _activeOfflineMissionSeed = request.Seed.Value;
1521  
1522              var result = _localCombatService.CreateSessionAsync(request).GetAwaiter().GetResult();
1523              if (!result.IsSuccess)
1524              {
1525                  ErrorMessage = result.ErrorMessage;
1526                  return false;
1527              }
1528  
1529              CurrentSession = ToLocalDto(result.Value!);
1530              ActiveSessionId = CurrentSession.Id;
1531              _usingLocalCombat = true;
1532              return true;
1533          }
1534          catch (Exception ex)
1535          {
1536              ErrorMessage = ex.Message;
1537              return false;
1538          }
1539      }
1540  
1541      bool StartNewCombatSession()
1542      {
1543          ErrorMessage = null;
1544  
1545          if (CurrentOperatorId == null || CurrentOperator == null)
1546          {
1547              return false;
1548          }
1549  
1550          try
1551          {
1552              // The server creates both the operator event and the combat session atomically,
1553              // so a single POST is sufficient — no separate /sessions call is needed.
1554              using var response = client.PostAsync($"operators/{CurrentOperatorId}/infil/combat", null).GetAwaiter().GetResult();
1555              if (!response.IsSuccessStatusCode)
1556              {
1557                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
1558                  ErrorMessage = $"Failed to start combat session: {response.StatusCode} - {errorContent}";
1559                  return false;
1560              }
1561  
1562              var newSessionId = response.Content.ReadFromJsonAsync<Guid>(options).GetAwaiter().GetResult();
1563  
1564              // Fetch the session state that the server just created
1565              var sessionState = client.GetFromJsonAsync<CombatSessionDto>($"sessions/{newSessionId}/state", options).GetAwaiter().GetResult();
1566              if (sessionState == null)
1567              {
1568                  ErrorMessage = "Failed to retrieve newly created combat session";
1569                  return false;
1570              }
1571  
1572              CurrentSession = sessionState;
1573              // Update ActiveSessionId to point to the new session
1574              ActiveSessionId = newSessionId;
1575  
1576              return true;
1577          }
1578          catch (Exception ex)
1579          {
1580              ErrorMessage = ex.Message;
1581              return false;
1582          }
1583      }
1584  
1585      Hex1bWidget BuildCombatSession()
1586      {
1587          if (_raidState == RaidState.ExfilFailed)
1588          {
1589              return BuildExfilFailed();
1590          }
1591  
1592          var session = CurrentSession;
1593          if (session == null)
1594          {
1595              return new TextBlockWidget("No session loaded");
1596          }
1597  
1598          var player = session.Player;
1599          var enemy = session.Enemy;
1600  
1601          // Check if combat has ended
1602          var combatEnded = player.Health <= 0 || enemy.Health <= 0 || session.Phase == "Completed";
1603  
1604          // Create progress bars for HP
1605          var playerHpBar = UI.CreateProgressBar("HP", (int)player.Health, (int)player.MaxHealth, 20);
1606          var enemyHpBar = UI.CreateProgressBar("HP", (int)enemy.Health, (int)enemy.MaxHealth, 20);
1607  
1608          // Create progress bars for stamina
1609          var playerStaminaBar = UI.CreateProgressBar("STA", (int)player.Stamina, 100, 15);
1610  
1611          // Create ADS progress indicator (showing aim state)
1612          var adsStatus = player.AimState switch
1613          {
1614              "ADS" => "[ADS]",
1615              "Hip" or "HIP" => "[HIP]",
1616              "TransitioningToADS" or "TransitioningToHip" => "[TRANS]",
1617              _ => $"[{player.AimState}]"
1618          };
1619  
1620          // Create cover visual representation
1621          var coverVisual = UI.CreateCoverVisual(player.CurrentCover);
1622  
1623          // Create battle log display (Pokemon-style)
1624          var battleLogWidget = UI.CreateBattleLogDisplay(session.BattleLog);
1625  
1626          // Build action menu based on combat state
1627          var actionItems = new List<string>();
1628          if (!combatEnded)
1629          {
1630              actionItems.Add("SUBMIT INTENTS");
1631          }
1632          actionItems.Add("VIEW DETAILS");
1633          actionItems.Add("RETREAT");
1634  
1635          var combatContentWidget = new VStackWidget([
1636              UI.CreateBorder("⚔ COMBAT MISSION ⚔"),
1637              UI.CreateBorder("EXFIL TIMER", BuildExfilTimerWidget()),
1638              new TextBlockWidget(""),
1639              new HStackWidget([
1640                  // Player column
1641                  UI.CreateBorder("🎮 PLAYER", new VStackWidget([
1642                      new TextBlockWidget($"  {player.Name}"),
1643                      new TextBlockWidget("  "),
1644                      playerHpBar,
1645                      playerStaminaBar,
1646                      new TextBlockWidget($"  AMMO: {player.CurrentAmmo}/{player.MagazineSize} {adsStatus}"),
1647                      new TextBlockWidget("  "),
1648                      new TextBlockWidget($"  {coverVisual}"),
1649                      new TextBlockWidget($"  MOVE: {player.MovementState}")
1650                  ])),
1651                  new TextBlockWidget("    "),
1652                  // Enemy column
1653                  UI.CreateBorder("💀 ENEMY", new VStackWidget([
1654                      new TextBlockWidget($"  {enemy.Name} (LVL {session.EnemyLevel})"),
1655                      new TextBlockWidget("  "),
1656                      enemyHpBar,
1657                      new TextBlockWidget($"  AMMO: {enemy.CurrentAmmo}/{enemy.MagazineSize}"),
1658                      new TextBlockWidget($"  DIST: {player.DistanceToOpponent:F2}m"),
1659                      new TextBlockWidget($"  COVER: {enemy.CurrentCover}")
1660                  ]))
1661              ]),
1662              new TextBlockWidget(""),
1663              // Battle log display - Pokemon Red style
1664              battleLogWidget,
1665              new TextBlockWidget(""),
1666              UI.CreateBorder("ACTIONS", new VStackWidget([
1667                  new TextBlockWidget($"  TURN: {session.TurnNumber}  PHASE: {session.Phase}  TIME: {session.CurrentTimeMs}ms"),
1668                  new TextBlockWidget(""),
1669                  new ListWidget(actionItems.ToArray()).OnItemActivated(e => {
1670                      if (_raidState == RaidState.ExfilFailed)
1671                      {
1672                          return;
1673                      }
1674  
1675                      var selectedAction = actionItems[e.ActivatedIndex];
1676                      switch (selectedAction)
1677                      {
1678                          case "SUBMIT INTENTS":
1679                              // Reset intent selections to defaults then open the floating window
1680                              SelectedPrimary = "None";
1681                              SelectedMovement = "Stand";
1682                              SelectedStance = "None";
1683                              SelectedCover = "None";
1684                              SelectedCategory = IntentCategory.Primary;
1685                              e.Popups.Push(() => BuildSubmitIntentsContent(session)).AsBarrier();
1686                              break;
1687                          case "VIEW DETAILS":
1688                              var pet = session.Pet;
1689                              Message = $"Combat Details:\n\nPlayer: {session.Player.Name}\nHealth: {session.Player.Health:F0}/{session.Player.MaxHealth:F0}\nAmmo: {session.Player.CurrentAmmo}/{session.Player.MagazineSize}\n\nEnemy: {session.Enemy.Name}\nHealth: {session.Enemy.Health:F0}/{session.Enemy.MaxHealth:F0}\n\nPet Health: {pet.Health:F0}\nPet Morale: {pet.Morale:F0}\n\nPress OK to continue.";
1690                              CurrentScreen = Screen.Message;
1691                              ReturnScreen = Screen.CombatSession;
1692                              break;
1693                          case "RETREAT":
1694                              // Process combat outcome if ended
1695                              if (combatEnded)
1696                              {
1697                                  if (_usingLocalCombat)
1698                                      ProcessCombatOutcomeOffline();
1699                                  else
1700                                      ProcessCombatOutcome();
1701                              }
1702                              // Retreat: clear the operator's combat session reference
1703                              // (session record is preserved in the database for audit purposes)
1704                              if (ActiveSessionId.HasValue)
1705                              {
1706                                  if (!_usingLocalCombat)
1707                                  {
1708                                      bool retreatOk = false;
1709                                      try
1710                                      {
1711                                          // Retreat server-side: emits CombatVictoryEvent to clear
1712                                          // ActiveCombatSessionId on the operator, session stays in DB.
1713                                          using var retreatResponse = client.PostAsync($"operators/{CurrentOperatorId}/infil/retreat", null).GetAwaiter().GetResult();
1714                                          retreatOk = retreatResponse.IsSuccessStatusCode;
1715                                      }
1716                                      catch
1717                                      {
1718                                          retreatOk = false;
1719                                      }
1720  
1721                                      if (!retreatOk)
1722                                      {
1723                                          Message = "Failed to retreat: server request failed.\nPlease try again.";
1724                                          CurrentScreen = Screen.Message;
1725                                          ReturnScreen = Screen.CombatSession;
1726                                          break;
1727                                      }
1728                                  }
1729                                  // Local (offline) sessions: session is stored in offline.db and stays there
1730                                  // as an audit record; only the in-memory reference is cleared below.
1731                              }
1732                              _usingLocalCombat = false;
1733                              ActiveSessionId = null;
1734                              CurrentSession = null;
1735                              RefreshOperator();
1736                              Message = "Retreated from combat.\nYou remain in infil mode.\n\nPress OK to continue.";
1737                              CurrentScreen = Screen.Message;
1738                              ReturnScreen = Screen.BaseCamp;
1739                              break;
1740                      }
1741                  })
1742              ])),
1743              UI.CreateStatusBar($"Session: {session.Id}")
1744          ]);
1745  
1746          return combatContentWidget;
1747      }
1748  
1749      Hex1bWidget BuildSubmitIntentsContent(CombatSessionDto session)
1750      {
1751          var player = session.Player;
1752  
1753          // Determine which options are incompatible with the current player state
1754          bool fireIncompatible = player.CurrentAmmo <= 0;
1755          bool reloadIncompatible = player.MagazineSize.HasValue && player.CurrentAmmo >= player.MagazineSize.Value;
1756          bool sprintIncompatible = player.Stamina <= 0;
1757          bool slideIncompatible = player.Stamina < 30;
1758          bool enterAdsIncompatible = player.AimState == "ADS"
1759              || player.MovementState == "Sliding";
1760          bool exitAdsIncompatible = player.AimState == "Hip";
1761          bool enterCoverIncompatible = player.CurrentCover != "None"
1762              || (player.MovementState != "Stationary"
1763                  && player.MovementState != "Idle"
1764                  && player.MovementState != "Crouching");
1765          bool exitCoverIncompatible = player.CurrentCover == "None";
1766  
1767          // Prefix incompatible items with ⊘ (2 chars: ⊘ + space) so they are visually greyed out
1768          static string Mark(string name, bool incompatible) => incompatible ? $"⊘ {name}" : name;
1769          static string Unmark(string name) => name.StartsWith("⊘ ") ? name[2..] : name;
1770          static bool IsCompatible(string name) => !name.StartsWith("⊘ ");
1771  
1772          var primaryActions = new[]
1773          {
1774              "None",
1775              Mark("Fire", fireIncompatible),
1776              Mark("Reload", reloadIncompatible),
1777          };
1778          var movementActions = new[]
1779          {
1780              "Stand",
1781              "WalkToward",
1782              "WalkAway",
1783              Mark("SprintToward", sprintIncompatible),
1784              Mark("SprintAway", sprintIncompatible),
1785              Mark("SlideToward", slideIncompatible),
1786              Mark("SlideAway", slideIncompatible),
1787              "Crouch",
1788          };
1789          var stanceActions = new[]
1790          {
1791              "None",
1792              Mark("EnterADS", enterAdsIncompatible),
1793              Mark("ExitADS", exitAdsIncompatible),
1794          };
1795          var coverActions = new[]
1796          {
1797              "None",
1798              Mark("EnterPartial", enterCoverIncompatible),
1799              Mark("EnterFull", enterCoverIncompatible),
1800              Mark("Exit", exitCoverIncompatible),
1801          };
1802  
1803          return new BorderWidget(new VStackWidget([
1804              new TextBlockWidget($"  {player.Name}: HP {player.Health:F0}/{player.MaxHealth:F0}  AMMO: {player.CurrentAmmo}/{player.MagazineSize}  Stamina: {player.Stamina:F0}"),
1805              new TextBlockWidget(""),
1806              new HStackWidget([
1807                  new BorderWidget(new ListWidget(primaryActions)
1808                      .OnSelectionChanged(e =>
1809                      {
1810                          if (IsCompatible(e.SelectedText))
1811                              SelectedPrimary = Unmark(e.SelectedText);
1812                      })).Title("🎯 PRIMARY"),
1813                  new TextBlockWidget(" "),
1814                  new BorderWidget(new ListWidget(movementActions)
1815                      .OnSelectionChanged(e =>
1816                      {
1817                          if (IsCompatible(e.SelectedText))
1818                              SelectedMovement = Unmark(e.SelectedText);
1819                      })).Title("🏃 MOVEMENT"),
1820                  new TextBlockWidget(" "),
1821                  new BorderWidget(new ListWidget(stanceActions)
1822                      .OnSelectionChanged(e =>
1823                      {
1824                          if (IsCompatible(e.SelectedText))
1825                              SelectedStance = Unmark(e.SelectedText);
1826                      })).Title("🧍 STANCE"),
1827                  new TextBlockWidget(" "),
1828                  new BorderWidget(new ListWidget(coverActions)
1829                      .OnSelectionChanged(e =>
1830                      {
1831                          if (IsCompatible(e.SelectedText))
1832                              SelectedCover = Unmark(e.SelectedText);
1833                      })).Title("🏠 COVER"),
1834              ]),
1835              new TextBlockWidget(""),
1836              UI.CreateBorder("SELECTIONS", new HStackWidget([
1837                  new TextBlockWidget($"  PRIMARY: {SelectedPrimary}"),
1838                  new TextBlockWidget($"   MOVEMENT: {SelectedMovement}"),
1839                  new TextBlockWidget($"   STANCE: {SelectedStance}"),
1840                  new TextBlockWidget($"   COVER: {SelectedCover}"),
1841              ])),
1842              new TextBlockWidget(""),
1843              new HStackWidget([
1844                  new TextBlockWidget("  "),
1845                  new ButtonWidget("SUBMIT & ADVANCE TURN").OnClick(e => {
1846                      // Always pop the popup so the next screen (combat, error, or mission complete) is unblocked
1847                      if (SubmitPlayerIntents())
1848                      {
1849                          // NOTE: AdvanceCombat blocks on HTTP calls due to hex1b's synchronous event handlers.
1850                          AdvanceCombat();
1851                      }
1852                      e.Popups.Pop();
1853                  }),
1854                  new TextBlockWidget("  "),
1855                  new ButtonWidget("CANCEL").OnClick(e => e.Popups.Pop()),
1856              ])
1857          ])).Title("⚡ SUBMIT INTENTS ⚡");
1858      }
1859  
1860      void AdvanceCombat()
1861      {
1862          if (_raidState == RaidState.ExfilFailed)
1863          {
1864              return;
1865          }
1866  
1867          if (_usingLocalCombat)
1868          {
1869              AdvanceCombatOffline();
1870              return;
1871          }
1872  
1873          try
1874          {
1875              var response = client.PostAsync($"sessions/{ActiveSessionId}/advance", null).GetAwaiter().GetResult();
1876              
1877              if (!response.IsSuccessStatusCode)
1878              {
1879                  ErrorMessage = $"Advance failed: {response.StatusCode}";
1880                  return;
1881              }
1882  
1883              var sessionData = response.Content.ReadFromJsonAsync<CombatSessionDto>(options).GetAwaiter().GetResult();
1884              if (sessionData != null)
1885              {
1886                  CurrentSession = sessionData;
1887                  ApplyPendingRaidStateTransitions();
1888                  if (_raidState == RaidState.ExfilFailed)
1889                  {
1890                      return;
1891                  }
1892                  
1893                  if (sessionData.Player.Health <= 0 || sessionData.Enemy.Health <= 0)
1894                  {
1895                      // Combat has ended - process the outcome
1896                      ProcessCombatOutcome();
1897                      
1898                      // Clear local session state to prevent auto-resume after death
1899                      ActiveSessionId = null;
1900                      CurrentSession = null;
1901                      
1902                      Message = sessionData.Player.Health <= 0 
1903                          ? "MISSION FAILED\n\nYou were eliminated.\n\nPress OK to continue."
1904                          : "MISSION SUCCESS\n\nTarget eliminated.\n\nPress OK to continue.";
1905                      CurrentScreen = Screen.MissionComplete;
1906                  }
1907              }
1908          }
1909          catch (Exception ex)
1910          {
1911              ErrorMessage = ex.Message;
1912          }
1913      }
1914  
1915      void AdvanceCombatOffline()
1916      {
1917          if (_raidState == RaidState.ExfilFailed)
1918          {
1919              return;
1920          }
1921  
1922          try
1923          {
1924              var result = _localCombatService!.AdvanceAsync(ActiveSessionId!.Value).GetAwaiter().GetResult();
1925              if (!result.IsSuccess)
1926              {
1927                  ErrorMessage = $"Advance failed: {result.ErrorMessage}";
1928                  return;
1929              }
1930  
1931              CurrentSession = ToLocalDto(result.Value!);
1932              ApplyPendingRaidStateTransitions();
1933              if (_raidState == RaidState.ExfilFailed)
1934              {
1935                  return;
1936              }
1937  
1938              if (CurrentSession.Player.Health <= 0 || CurrentSession.Enemy.Health <= 0)
1939              {
1940                  // Combat ended — update offline snapshot with outcome, then clear local combat flag
1941                  ProcessCombatOutcomeOffline();
1942                  _usingLocalCombat = false;
1943  
1944                  // Keep CurrentSession for display on MissionComplete screen.
1945                  // The completed local session record is preserved in offline.db for audit purposes.
1946                  ActiveSessionId = null;
1947  
1948                  Message = CurrentSession.Player.Health <= 0
1949                      ? "MISSION FAILED\n\nYou were eliminated.\n\nPress OK to continue."
1950                      : "MISSION SUCCESS\n\nTarget eliminated.\n\nPress OK to continue.";
1951                  CurrentScreen = Screen.MissionComplete;
1952              }
1953          }
1954          catch (Exception ex)
1955          {
1956              ErrorMessage = ex.Message;
1957          }
1958      }
1959  
1960      void ProcessCombatOutcome()
1961      {
1962          ApplyPendingRaidStateTransitions();
1963          if (_raidState == RaidState.ExfilFailed)
1964          {
1965              return;
1966          }
1967  
1968          try
1969          {
1970              // Use authoritative session ID with fallback
1971              var sessionId = ActiveSessionId ?? CurrentOperator?.ActiveCombatSessionId;
1972              
1973              // Guard against null sessionId - API requires non-null Guid
1974              if (!sessionId.HasValue)
1975              {
1976                  ErrorMessage = "No active session found to process outcome";
1977                  return;
1978              }
1979              
1980              // NOTE: Using empty request body - server will load session and compute outcome
1981              var request = new { SessionId = sessionId.Value };
1982              using var response = client.PostAsJsonAsync($"operators/{CurrentOperatorId}/infil/outcome", request, options)
1983                  .GetAwaiter().GetResult();
1984              
1985              if (!response.IsSuccessStatusCode)
1986              {
1987                  // Check if this is an InvalidState error (operator already in Base mode)
1988                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
1989                  if (errorContent.Contains("InvalidState") || errorContent.Contains("already in Base mode"))
1990                  {
1991                      // Silently ignore - operator is already in the correct state
1992                      return;
1993                  }
1994                  
1995                  ErrorMessage = $"Failed to process outcome: {response.StatusCode}";
1996                  return;
1997              }
1998  
1999              // Refresh operator state to reflect outcome (XP, mode change, etc.)
2000              RefreshOperator();
2001          }
2002          catch (Exception ex)
2003          {
2004              ErrorMessage = $"Outcome processing error: {ex.Message}";
2005          }
2006      }
2007  
2008      /// <summary>
2009      /// Processes a combat outcome for an offline session using the deterministic combat engine.
2010      /// The engine computes the authoritative result from the initial operator state and seed,
2011      /// ensuring the server can independently replay and verify the outcome.
2012      /// </summary>
2013      void ProcessCombatOutcomeOffline()
2014      {
2015          ApplyPendingRaidStateTransitions();
2016          if (_raidState == RaidState.ExfilFailed)
2017              return;
2018  
2019          if (CurrentOperator == null || offlineStore == null)
2020              return;
2021  
2022          var initialDto = ToBackendDto(CurrentOperator);
2023          var initialHash = OfflineMissionHashing.ComputeOperatorStateHash(initialDto);
2024  
2025          // Run the deterministic combat engine — same engine the server will use to verify.
2026          var combatResult = _deterministicEngine.Execute(initialDto, _activeOfflineMissionSeed);
2027          var updatedDto = combatResult.ResultOperator;
2028  
2029          var resultHash = OfflineMissionHashing.ComputeOperatorStateHash(updatedDto);
2030          var nextSequence = offlineStore.GetNextMissionSequence(updatedDto.Id);
2031  
2032          var initialSnapshotJson = JsonSerializer.Serialize(initialDto, options);
2033          var resultSnapshotJson = JsonSerializer.Serialize(updatedDto, options);
2034  
2035          Console.WriteLine($"[OFFLINE] Envelope seq={nextSequence} seed={_activeOfflineMissionSeed} initialHash={initialHash} resultHash={resultHash}");
2036  
2037          var envelope = new OfflineMissionEnvelope
2038          {
2039              OperatorId = updatedDto.Id,
2040              SequenceNumber = nextSequence,
2041              RandomSeed = _activeOfflineMissionSeed,
2042              InitialSnapshotJson = initialSnapshotJson,
2043              ResultSnapshotJson = resultSnapshotJson,
2044              InitialOperatorStateHash = initialHash,
2045              ResultOperatorStateHash = resultHash,
2046              FullBattleLog = combatResult.BattleLog,
2047              ExecutedUtc = DateTime.UtcNow,
2048              Synced = false
2049          };
2050  
2051          offlineStore.SaveMissionResult(envelope);
2052          offlineStore.UpdateOperatorSnapshot(updatedDto.Id, updatedDto);
2053          CurrentOperator = OperatorStateFromDto(updatedDto);
2054      }
2055  
2056      private static OperatorDto ToBackendDto(OperatorState state)
2057      {
2058          return new OperatorDto
2059          {
2060              Id = state.Id.ToString(),
2061              Name = state.Name,
2062              TotalXp = state.TotalXp,
2063              CurrentHealth = state.CurrentHealth,
2064              MaxHealth = state.MaxHealth,
2065              EquippedWeaponName = state.EquippedWeaponName,
2066              UnlockedPerks = state.UnlockedPerks,
2067              ExfilStreak = state.ExfilStreak,
2068              IsDead = state.IsDead,
2069              CurrentMode = state.CurrentMode,
2070              ActiveCombatSessionId = state.ActiveCombatSessionId,
2071              InfilSessionId = state.InfilSessionId,
2072              InfilStartTime = state.InfilStartTime,
2073              LockedLoadout = state.LockedLoadout,
2074              Pet = state.Pet == null ? null : new GUNRPG.Application.Dtos.PetStateDto
2075              {
2076                  Health = state.Pet.Health,
2077                  Fatigue = state.Pet.Fatigue,
2078                  Injury = state.Pet.Injury,
2079                  Stress = state.Pet.Stress,
2080                  Morale = state.Pet.Morale,
2081                  Hunger = state.Pet.Hunger,
2082                  Hydration = state.Pet.Hydration,
2083                  LastUpdated = state.Pet.LastUpdated
2084              }
2085          };
2086      }
2087  
2088      void RefreshOperator()
2089      {
2090          if (_backend is not OnlineGameBackend)
2091          {
2092              // Offline: re-read from local snapshot without any HTTP calls
2093              try
2094              {
2095                  if (CurrentOperatorId.HasValue)
2096                  {
2097                      var dto = _backend.GetOperatorAsync(CurrentOperatorId.Value.ToString()).GetAwaiter().GetResult();
2098                      if (dto != null)
2099                          CurrentOperator = OperatorStateFromDto(dto);
2100                  }
2101              }
2102              catch
2103              {
2104                  // Silently fail - operator state will be stale but UI remains functional
2105              }
2106              return;
2107          }
2108  
2109          try
2110          {
2111              // First, cleanup any completed sessions to prevent stuck state
2112              try
2113              {
2114                  using var cleanupResponse = client.PostAsync($"operators/{CurrentOperatorId}/cleanup", null).GetAwaiter().GetResult();
2115                  // Cleanup failures are non-fatal; the Get operation will handle dangling references
2116              }
2117              catch
2118              {
2119                  // Silently ignore cleanup errors; Get will still work
2120              }
2121  
2122              using var response = client.GetAsync($"operators/{CurrentOperatorId}").GetAwaiter().GetResult();
2123              if (response.IsSuccessStatusCode)
2124              {
2125                  var operatorDto = response.Content.ReadFromJsonAsync<JsonElement>(options).GetAwaiter().GetResult();
2126                  CurrentOperator = ParseOperator(operatorDto);
2127                  
2128                  // Auto-resume combat if operator has active session, is still in Infil mode, and we're not already in combat
2129                  if (CurrentScreen != Screen.CombatSession && 
2130                      CurrentOperator?.CurrentMode == "Infil" &&
2131                      operatorDto.TryGetProperty("activeCombatSession", out var sessionJson) && 
2132                      sessionJson.ValueKind != JsonValueKind.Null)
2133                  {
2134                      CurrentSession = ParseSession(sessionJson, options);
2135                      ActiveSessionId = CurrentSession?.Id;
2136                      CurrentScreen = Screen.CombatSession;
2137                  }
2138              }
2139          }
2140          catch (Exception)
2141          {
2142              // Silently fail - operator state will be stale but UI remains functional
2143          }
2144      }
2145  
2146  
2147      bool SubmitPlayerIntents()
2148      {
2149          ApplyPendingRaidStateTransitions();
2150          if (_raidState == RaidState.ExfilFailed)
2151          {
2152              return false;
2153          }
2154  
2155          if (_usingLocalCombat)
2156          {
2157              return SubmitPlayerIntentsOffline();
2158          }
2159  
2160          try
2161          {
2162              var request = new
2163              {
2164                  intents = new
2165                  {
2166                      primary = SelectedPrimary,
2167                      movement = SelectedMovement,
2168                      stance = SelectedStance,
2169                      cover = SelectedCover,
2170                      cancelMovement = false
2171                  }
2172              };
2173  
2174              using var response = client.PostAsJsonAsync($"sessions/{ActiveSessionId}/intent", request, options)
2175                  .GetAwaiter().GetResult();
2176              
2177              if (!response.IsSuccessStatusCode)
2178              {
2179                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
2180                  ErrorMessage = $"Intent submission failed: {response.StatusCode} - {errorContent}";
2181                  Message = $"Intent submission failed:\n\n{errorContent}\n\nPress OK to continue.";
2182                  CurrentScreen = Screen.Message;
2183                  ReturnScreen = Screen.CombatSession;
2184                  return false;
2185              }
2186  
2187              // Reload session state
2188              var sessionData = response.Content.ReadFromJsonAsync<CombatSessionDto>(options).GetAwaiter().GetResult();
2189              if (sessionData != null)
2190              {
2191                  CurrentSession = sessionData;
2192  
2193                  ApplyPendingRaidStateTransitions();
2194                  if (_raidState == RaidState.ExfilFailed)
2195                  {
2196                      return false;
2197                  }
2198              }
2199  
2200              return true;
2201          }
2202          catch (Exception ex)
2203          {
2204              ErrorMessage = ex.Message;
2205              Message = $"Error submitting intents:\n\n{ex.Message}\n\nPress OK to continue.";
2206              CurrentScreen = Screen.Message;
2207              ReturnScreen = Screen.CombatSession;
2208              return false;
2209          }
2210      }
2211  
2212      bool SubmitPlayerIntentsOffline()
2213      {
2214          ApplyPendingRaidStateTransitions();
2215          if (_raidState == RaidState.ExfilFailed)
2216          {
2217              return false;
2218          }
2219  
2220          try
2221          {
2222              var intents = new GUNRPG.Application.Dtos.IntentDto
2223              {
2224                  Primary = Enum.TryParse<PrimaryAction>(SelectedPrimary, out var p) ? p : PrimaryAction.None,
2225                  Movement = Enum.TryParse<MovementAction>(SelectedMovement, out var m) ? m : MovementAction.Stand,
2226                  Stance = Enum.TryParse<StanceAction>(SelectedStance, out var st) ? st : StanceAction.None,
2227                  Cover = Enum.TryParse<CoverAction>(SelectedCover, out var c) ? c : CoverAction.None,
2228                  CancelMovement = false
2229              };
2230  
2231              var result = _localCombatService!.SubmitPlayerIntentsAsync(
2232                  ActiveSessionId!.Value,
2233                  new SubmitIntentsRequest { Intents = intents })
2234                  .GetAwaiter().GetResult();
2235  
2236              if (!result.IsSuccess)
2237              {
2238                  ErrorMessage = $"Intent submission failed: {result.ErrorMessage}";
2239                  Message = $"Intent submission failed:\n\n{result.ErrorMessage}\n\nPress OK to continue.";
2240                  CurrentScreen = Screen.Message;
2241                  ReturnScreen = Screen.CombatSession;
2242                  return false;
2243              }
2244  
2245              CurrentSession = ToLocalDto(result.Value!);
2246              ApplyPendingRaidStateTransitions();
2247              if (_raidState == RaidState.ExfilFailed)
2248              {
2249                  return false;
2250              }
2251              return true;
2252          }
2253          catch (Exception ex)
2254          {
2255              ErrorMessage = ex.Message;
2256              Message = $"Error submitting intents:\n\n{ex.Message}\n\nPress OK to continue.";
2257              CurrentScreen = Screen.Message;
2258              ReturnScreen = Screen.CombatSession;
2259              return false;
2260          }
2261      }
2262  
2263      Hex1bWidget BuildMissionComplete()
2264      {
2265          return new VStackWidget([
2266              UI.CreateBorder("MISSION COMPLETE"),
2267              new TextBlockWidget(""),
2268              UI.CreateBorder("DEBRIEFING", new VStackWidget([
2269                  new TextBlockWidget(""),
2270                  new TextBlockWidget(Message ?? "Mission ended."),
2271                  new TextBlockWidget(""),
2272                  new ListWidget(new[] { "OK" }).OnItemActivated(_ => {
2273                      ActiveSessionId = null;
2274                      CurrentSession = null;
2275                      RefreshOperator();
2276                      CurrentScreen = Screen.BaseCamp;
2277                  })
2278              ])),
2279              UI.CreateStatusBar("Mission complete")
2280          ]);
2281      }
2282  
2283      Hex1bWidget BuildMessage()
2284      {
2285          var lines = Message?.Split('\n') ?? [""];
2286          var contentWidgets = new List<Hex1bWidget>();
2287          
2288          foreach (var line in lines)
2289          {
2290              contentWidgets.Add(new TextBlockWidget($"  {line}"));
2291          }
2292          
2293          contentWidgets.Add(new TextBlockWidget(""));
2294          contentWidgets.Add(new ListWidget(new[] { "OK" }).OnItemActivated(_ => {
2295              // Refresh operator state before returning to BaseCamp to ensure menu shows correct mode
2296              if (ReturnScreen == Screen.BaseCamp)
2297              {
2298                  RefreshOperator();
2299              }
2300              CurrentScreen = ReturnScreen;
2301          }));
2302  
2303          return new VStackWidget([
2304              UI.CreateBorder("MESSAGE"),
2305              new TextBlockWidget(""),
2306              UI.CreateBorder("INFO", new VStackWidget(contentWidgets)),
2307              UI.CreateStatusBar("Press OK to continue")
2308          ]);
2309      }
2310  
2311      Hex1bWidget BuildChangeLoadout()
2312      {
2313          var availableWeapons = new[] {
2314              "SOKOL 545",
2315              "STURMWOLF 45",
2316              "M15 MOD 0",
2317              "--- CANCEL ---"
2318          };
2319  
2320          return new VStackWidget([
2321              UI.CreateBorder("CHANGE LOADOUT"),
2322              new TextBlockWidget(""),
2323              UI.CreateBorder("AVAILABLE WEAPONS", new VStackWidget([
2324                  new TextBlockWidget("  Select a weapon to equip:"),
2325                  new TextBlockWidget(""),
2326                  new TextBlockWidget($"  Current: {CurrentOperator?.EquippedWeaponName ?? "None"}"),
2327                  new TextBlockWidget(""),
2328                  new ListWidget(availableWeapons).OnItemActivated(e => {
2329                      if (e.ActivatedIndex == availableWeapons.Length - 1)
2330                      {
2331                          // Cancel
2332                          CurrentScreen = Screen.BaseCamp;
2333                      }
2334                      else
2335                      {
2336                          ChangeLoadout(availableWeapons[e.ActivatedIndex]);
2337                      }
2338                  })
2339              ])),
2340              UI.CreateStatusBar("Choose a weapon")
2341          ]);
2342      }
2343  
2344      void ChangeLoadout(string weaponName)
2345      {
2346          try
2347          {
2348              var request = new { WeaponName = weaponName };
2349              var response = client.PostAsJsonAsync($"operators/{CurrentOperatorId}/loadout", request, options)
2350                  .GetAwaiter().GetResult();
2351              
2352              if (!response.IsSuccessStatusCode)
2353              {
2354                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
2355                  ErrorMessage = $"Failed to change loadout: {response.StatusCode} - {errorContent}";
2356                  Message = $"Loadout change failed.\nError: {ErrorMessage}\n\nPress OK to continue.";
2357                  CurrentScreen = Screen.Message;
2358                  ReturnScreen = Screen.BaseCamp;
2359                  return;
2360              }
2361  
2362              // Refresh operator state
2363              RefreshOperator();
2364              
2365              Message = $"Loadout changed successfully.\nNew weapon: {weaponName}\n\nPress OK to continue.";
2366              CurrentScreen = Screen.Message;
2367              ReturnScreen = Screen.BaseCamp;
2368          }
2369          catch (Exception ex)
2370          {
2371              ErrorMessage = ex.Message;
2372              Message = $"Error changing loadout: {ex.Message}\n\nPress OK to continue.";
2373              CurrentScreen = Screen.Message;
2374              ReturnScreen = Screen.BaseCamp;
2375          }
2376      }
2377  
2378      Hex1bWidget BuildTreatWounds()
2379      {
2380          var op = CurrentOperator;
2381          if (op == null)
2382          {
2383              return new TextBlockWidget("No operator loaded");
2384          }
2385  
2386          var maxHeal = op.MaxHealth - op.CurrentHealth;
2387          var healOptions = new List<(string display, float amount)>();
2388          
2389          if (maxHeal > 0)
2390          {
2391              if (maxHeal >= 25) healOptions.Add(("HEAL 25 HP", 25f));
2392              if (maxHeal >= 50) healOptions.Add(("HEAL 50 HP", 50f));
2393              if (maxHeal >= 100) healOptions.Add(("HEAL 100 HP", 100f));
2394              healOptions.Add(($"HEAL ALL ({maxHeal:F0} HP)", maxHeal));
2395          }
2396          else
2397          {
2398              healOptions.Add(("ALREADY AT FULL HEALTH", 0f));
2399          }
2400          
2401          healOptions.Add(("--- CANCEL ---", 0f));
2402          var displayOptions = healOptions.Select(h => h.display).ToArray();
2403  
2404          return new VStackWidget([
2405              UI.CreateBorder("TREAT WOUNDS"),
2406              new TextBlockWidget(""),
2407              UI.CreateBorder("MEDICAL", new VStackWidget([
2408                  new TextBlockWidget($"  Current Health: {op.CurrentHealth:F0}/{op.MaxHealth:F0}"),
2409                  new TextBlockWidget(""),
2410                  new TextBlockWidget("  Select healing amount:"),
2411                  new TextBlockWidget(""),
2412                  new ListWidget(displayOptions).OnItemActivated(e => {
2413                      if (e.ActivatedIndex == healOptions.Count - 1)
2414                      {
2415                          // Cancel
2416                          CurrentScreen = Screen.BaseCamp;
2417                      }
2418                      else if (healOptions[e.ActivatedIndex].amount > 0)
2419                      {
2420                          TreatWounds(healOptions[e.ActivatedIndex].amount);
2421                      }
2422                      else
2423                      {
2424                          // Already at full health
2425                          CurrentScreen = Screen.BaseCamp;
2426                      }
2427                  })
2428              ])),
2429              UI.CreateStatusBar("Choose healing amount")
2430          ]);
2431      }
2432  
2433      void TreatWounds(float healthAmount)
2434      {
2435          try
2436          {
2437              var request = new { HealthAmount = healthAmount };
2438              var response = client.PostAsJsonAsync($"operators/{CurrentOperatorId}/wounds/treat", request, options)
2439                  .GetAwaiter().GetResult();
2440              
2441              if (!response.IsSuccessStatusCode)
2442              {
2443                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
2444                  ErrorMessage = $"Failed to treat wounds: {response.StatusCode} - {errorContent}";
2445                  Message = $"Wound treatment failed.\nError: {ErrorMessage}\n\nPress OK to continue.";
2446                  CurrentScreen = Screen.Message;
2447                  ReturnScreen = Screen.BaseCamp;
2448                  return;
2449              }
2450  
2451              // Refresh operator state
2452              RefreshOperator();
2453              
2454              Message = $"Wounds treated successfully.\nHealed: {healthAmount:F0} HP\nNew Health: {CurrentOperator?.CurrentHealth:F0}/{CurrentOperator?.MaxHealth:F0}\n\nPress OK to continue.";
2455              CurrentScreen = Screen.Message;
2456              ReturnScreen = Screen.BaseCamp;
2457          }
2458          catch (Exception ex)
2459          {
2460              ErrorMessage = ex.Message;
2461              Message = $"Error treating wounds: {ex.Message}\n\nPress OK to continue.";
2462              CurrentScreen = Screen.Message;
2463              ReturnScreen = Screen.BaseCamp;
2464          }
2465      }
2466  
2467      Hex1bWidget BuildUnlockPerk()
2468      {
2469          var availablePerks = new[] {
2470              "Iron Lungs",
2471              "Quick Draw",
2472              "Toughness",
2473              "Fast Reload",
2474              "Steady Aim",
2475              "--- CANCEL ---"
2476          };
2477  
2478          var op = CurrentOperator;
2479          var unlockedPerks = op?.UnlockedPerks ?? new List<string>();
2480  
2481          return new VStackWidget([
2482              UI.CreateBorder("UNLOCK PERK"),
2483              new TextBlockWidget(""),
2484              UI.CreateBorder("AVAILABLE PERKS", new VStackWidget([
2485                  new TextBlockWidget("  Select a perk to unlock:"),
2486                  new TextBlockWidget(""),
2487                  new TextBlockWidget($"  Unlocked: {string.Join(", ", unlockedPerks)}"),
2488                  new TextBlockWidget(""),
2489                  new ListWidget(availablePerks).OnItemActivated(e => {
2490                      if (e.ActivatedIndex == availablePerks.Length - 1)
2491                      {
2492                          // Cancel
2493                          CurrentScreen = Screen.BaseCamp;
2494                      }
2495                      else
2496                      {
2497                          UnlockPerk(availablePerks[e.ActivatedIndex]);
2498                      }
2499                  })
2500              ])),
2501              UI.CreateStatusBar("Choose a perk")
2502          ]);
2503      }
2504  
2505      void UnlockPerk(string perkName)
2506      {
2507          try
2508          {
2509              var request = new { PerkName = perkName };
2510              var response = client.PostAsJsonAsync($"operators/{CurrentOperatorId}/perks", request, options)
2511                  .GetAwaiter().GetResult();
2512              
2513              if (!response.IsSuccessStatusCode)
2514              {
2515                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
2516                  ErrorMessage = $"Failed to unlock perk: {response.StatusCode} - {errorContent}";
2517                  Message = $"Perk unlock failed.\nError: {ErrorMessage}\n\nPress OK to continue.";
2518                  CurrentScreen = Screen.Message;
2519                  ReturnScreen = Screen.BaseCamp;
2520                  return;
2521              }
2522  
2523              // Refresh operator state
2524              RefreshOperator();
2525              
2526              Message = $"Perk unlocked successfully.\nNew perk: {perkName}\n\nPress OK to continue.";
2527              CurrentScreen = Screen.Message;
2528              ReturnScreen = Screen.BaseCamp;
2529          }
2530          catch (Exception ex)
2531          {
2532              ErrorMessage = ex.Message;
2533              Message = $"Error unlocking perk: {ex.Message}\n\nPress OK to continue.";
2534              CurrentScreen = Screen.Message;
2535              ReturnScreen = Screen.BaseCamp;
2536          }
2537      }
2538  
2539      Hex1bWidget BuildAbortMission()
2540      {
2541          var menuItems = new[] {
2542              "CONFIRM EXFIL",
2543              "CANCEL"
2544          };
2545  
2546          return new VStackWidget([
2547              UI.CreateBorder("EXFIL BACK TO BASE"),
2548              new TextBlockWidget(""),
2549              UI.CreateBorder("WARNING", new VStackWidget([
2550                  new TextBlockWidget("  Are you sure you want to exfil?"),
2551                  new TextBlockWidget(""),
2552                  new TextBlockWidget("  - Mission outcome will be processed"),
2553                  new TextBlockWidget("  - EXFIL streak and XP will reflect that outcome"),
2554                  new TextBlockWidget(""),
2555                  new TextBlockWidget("  Select action:"),
2556                  new TextBlockWidget(""),
2557                  new ListWidget(menuItems).OnItemActivated(e => {
2558                      switch (e.ActivatedIndex)
2559                      {
2560                          case 0: // CONFIRM EXFIL
2561                              ProcessExfil();
2562                              break;
2563                          case 1: // CANCEL
2564                              CurrentScreen = Screen.BaseCamp;
2565                              break;
2566                      }
2567                  })
2568              ])),
2569              UI.CreateStatusBar("Confirm exfil")
2570          ]);
2571      }
2572  
2573      Hex1bWidget BuildExfilFailed()
2574      {
2575          return new VStackWidget([
2576              UI.CreateBorder("EXFIL FAILED"),
2577              new TextBlockWidget(""),
2578              UI.CreateBorder("", new VStackWidget([
2579                  new TextBlockWidget("        EXFIL FAILED"),
2580                  new TextBlockWidget(""),
2581                  new TextBlockWidget("  You failed to extract in time."),
2582                  new TextBlockWidget("  Your operator has died."),
2583                  new TextBlockWidget(""),
2584                  new TextBlockWidget("  Press OK to return to base."),
2585                  new TextBlockWidget(""),
2586                  new ListWidget(new[] { "OK" }).OnItemActivated(_ => {
2587                      lock (_raidStateLock)
2588                      {
2589                          _raidState = RaidState.Completed;
2590                      }
2591                      CurrentScreen = Screen.BaseCamp;
2592                      ReturnScreen = Screen.BaseCamp;
2593                  })
2594              ])),
2595              UI.CreateStatusBar("Exfil window expired")
2596          ]);
2597      }
2598  
2599      void ProcessExfil()
2600      {
2601          try
2602          {
2603              // Get combat session ID (may be null after victory or if no combat started)
2604              var activeCombatSessionId = ActiveSessionId ?? CurrentOperator?.ActiveCombatSessionId;
2605              
2606              // If no active combat session, or the active session is not yet completed,
2607              // exfil using the /infil/complete endpoint (abandons any in-progress combat).
2608              // This matches the web client behaviour where Exfil is always available.
2609              if (!activeCombatSessionId.HasValue)
2610              {
2611                  CompleteInfilDirectly();
2612                  return;
2613              }
2614  
2615              // Check if the active combat session is completed so we can process the outcome.
2616              using var sessionStateResponse = client.GetAsync($"sessions/{activeCombatSessionId.Value}/state").GetAwaiter().GetResult();
2617              if (!sessionStateResponse.IsSuccessStatusCode)
2618              {
2619                  // Can't reach the session — fall back to infil/complete to clean up gracefully.
2620                  CompleteInfilDirectly();
2621                  return;
2622              }
2623  
2624              var sessionState = sessionStateResponse.Content.ReadFromJsonAsync<CombatSessionDto>(options).GetAwaiter().GetResult();
2625              if (sessionState?.Phase != "Completed")
2626              {
2627                  // Session is still in progress — use infil/complete to abandon combat and exfil.
2628                  CompleteInfilDirectly();
2629                  return;
2630              }
2631  
2632              // Session is completed: process the outcome via infil/outcome.
2633              var request = new { SessionId = activeCombatSessionId.Value };
2634              using var response = client.PostAsJsonAsync($"operators/{CurrentOperatorId}/infil/outcome", request, options)
2635                  .GetAwaiter().GetResult();
2636  
2637              if (!response.IsSuccessStatusCode)
2638              {
2639                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
2640                  
2641                  // If operator is already in Base mode, treat as success
2642                  if (errorContent.Contains("InvalidState") || errorContent.Contains("not in Infil mode"))
2643                  {
2644                      ActiveSessionId = null;
2645                      CurrentSession = null;
2646                      RefreshOperator();
2647                      Message = "Infil already ended.\nReturning to base.\n\nPress OK to continue.";
2648                      CurrentScreen = Screen.Message;
2649                      ReturnScreen = Screen.BaseCamp;
2650                      return;
2651                  }
2652  
2653                  ErrorMessage = $"Failed to exfil: {response.StatusCode} - {errorContent}";
2654                  Message = $"Exfil failed.\nError: {ErrorMessage}\n\nPress OK to continue.";
2655                  CurrentScreen = Screen.Message;
2656                  ReturnScreen = Screen.BaseCamp;
2657                  return;
2658              }
2659  
2660              // Clear session
2661              ActiveSessionId = null;
2662              CurrentSession = null;
2663  
2664              // Refresh operator state
2665              RefreshOperator();
2666  
2667              Message = "Exfil processed.\nReturning to base.\n\nPress OK to continue.";
2668              CurrentScreen = Screen.Message;
2669              ReturnScreen = Screen.BaseCamp;
2670          }
2671          catch (Exception ex)
2672          {
2673              ErrorMessage = ex.Message;
2674              Message = $"Error processing exfil: {ex.Message}\n\nPress OK to continue.";
2675              CurrentScreen = Screen.Message;
2676              ReturnScreen = Screen.BaseCamp;
2677          }
2678      }
2679  
2680      /// <summary>
2681      /// Completes the infil directly via <c>/infil/complete</c>, abandoning any in-progress
2682      /// combat session.  Used when the player exfils without a completed combat, or when
2683      /// the active session cannot be fetched.
2684      /// </summary>
2685      void CompleteInfilDirectly()
2686      {
2687          var infilSessionId = CurrentOperator?.InfilSessionId;
2688          if (!infilSessionId.HasValue)
2689          {
2690              Message = "Error: Invalid infil state.\nNo infil session found.\n\nPress OK to continue.";
2691              CurrentScreen = Screen.Message;
2692              ReturnScreen = Screen.BaseCamp;
2693              return;
2694          }
2695  
2696          // Calling infil/complete emits InfilEndedEvent which clears ActiveCombatSessionId on the
2697          // operator aggregate. Any in-progress session remains in the database as an audit record.
2698          using var completeResponse = client.SendAsync(
2699              new HttpRequestMessage(HttpMethod.Post, $"operators/{CurrentOperatorId}/infil/complete"))
2700              .GetAwaiter().GetResult();
2701  
2702          if (completeResponse.IsSuccessStatusCode)
2703          {
2704              ActiveSessionId = null;
2705              CurrentSession = null;
2706              RefreshOperator();
2707              Message = "Exfil successful!\nReturning to base.\n\nPress OK to continue.";
2708              CurrentScreen = Screen.Message;
2709              ReturnScreen = Screen.BaseCamp;
2710              return;
2711          }
2712  
2713          var errorContent = completeResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult();
2714          if (errorContent.Contains("InvalidState") || errorContent.Contains("not in Infil mode"))
2715          {
2716              ActiveSessionId = null;
2717              CurrentSession = null;
2718              RefreshOperator();
2719              Message = "Infil already ended.\nReturning to base.\n\nPress OK to continue.";
2720              CurrentScreen = Screen.Message;
2721              ReturnScreen = Screen.BaseCamp;
2722              return;
2723          }
2724  
2725          ErrorMessage = $"Failed to complete exfil: {completeResponse.StatusCode} - {errorContent}";
2726          Message = $"Exfil failed.\n{ErrorMessage}\n\nPress OK to continue.";
2727          CurrentScreen = Screen.Message;
2728          ReturnScreen = Screen.BaseCamp;
2729      }
2730  
2731      Hex1bWidget BuildExfilTimerWidget()
2732      {
2733          var remaining = GetRemainingRaidTime();
2734          if (!remaining.HasValue)
2735          {
2736              return new TextBlockWidget("  EXFIL WINDOW: --:-- REMAINING");
2737          }
2738  
2739          return new TextBlockWidget($"  {RaidTimer.FormatStyledRemainingLabel(remaining.Value)}");
2740      }
2741  
2742      void SyncRaidStateFromOperator()
2743      {
2744          lock (_raidStateLock)
2745          {
2746              if (CurrentOperator?.CurrentMode != "Infil" || !CurrentOperator.InfilStartTime.HasValue)
2747              {
2748                  _raidStartTimeUtc = null;
2749                  if (_raidState != RaidState.ExfilFailed)
2750                      _raidState = RaidState.Completed;
2751                  return;
2752              }
2753  
2754              _raidStartTimeUtc = CurrentOperator.InfilStartTime.Value.UtcDateTime;
2755              if (CurrentScreen == Screen.CombatSession)
2756                  _raidState = RaidState.Combat;
2757              else if (_raidState != RaidState.ExfilFailed)
2758                  _raidState = RaidState.Infil;
2759          }
2760      }
2761  
2762      TimeSpan? GetRemainingRaidTime()
2763      {
2764          lock (_raidStateLock)
2765          {
2766              return GetRemainingRaidTimeCore();
2767          }
2768      }
2769  
2770      TimeSpan? GetRemainingRaidTimeCore()
2771      {
2772          if (!_raidStartTimeUtc.HasValue)
2773              return null;
2774  
2775          return RaidTimer.ComputeRemaining(_raidStartTimeUtc.Value, RaidDuration);
2776      }
2777  
2778      void ApplyPendingRaidStateTransitions()
2779      {
2780          lock (_raidStateLock)
2781          {
2782              if (_exfilFailureRequested)
2783              {
2784                  _exfilFailureRequested = false;
2785                  ForceExfilFailure();
2786              }
2787          }
2788      }
2789  
2790      void ForceExfilFailure()
2791      {
2792          if (_raidState == RaidState.ExfilFailed)
2793          {
2794              return;
2795          }
2796  
2797          _raidState = RaidState.ExfilFailed;
2798          _usingLocalCombat = false;
2799          ActiveSessionId = null;
2800          CurrentSession = null;
2801  
2802          if (CurrentOperator != null)
2803          {
2804              CurrentOperator = new OperatorState
2805              {
2806                  Id = CurrentOperator.Id,
2807                  Name = CurrentOperator.Name,
2808                  TotalXp = CurrentOperator.TotalXp,
2809                  CurrentHealth = CurrentOperator.CurrentHealth,
2810                  MaxHealth = CurrentOperator.MaxHealth,
2811                  EquippedWeaponName = CurrentOperator.EquippedWeaponName,
2812                  UnlockedPerks = CurrentOperator.UnlockedPerks,
2813                  ExfilStreak = CurrentOperator.ExfilStreak,
2814                  IsDead = true,
2815                  CurrentMode = "Base",
2816                  InfilStartTime = null,
2817                  InfilSessionId = null,
2818                  ActiveCombatSessionId = null,
2819                  LockedLoadout = CurrentOperator.LockedLoadout,
2820                  Pet = CurrentOperator.Pet
2821              };
2822          }
2823  
2824          _raidStartTimeUtc = null;
2825          CurrentScreen = Screen.ExfilFailed;
2826      }
2827  
2828      Hex1bWidget BuildPetActions()
2829      {
2830          var op = CurrentOperator;
2831          if (op?.Pet == null)
2832          {
2833              return new VStackWidget([
2834                  UI.CreateBorder("PET ACTIONS"),
2835                  new TextBlockWidget(""),
2836                  new TextBlockWidget("  No pet data available."),
2837                  new TextBlockWidget(""),
2838                  new ListWidget(new[] { "BACK" }).OnItemActivated(_ => CurrentScreen = Screen.BaseCamp),
2839                  UI.CreateStatusBar("No pet available")
2840              ]);
2841          }
2842  
2843          var pet = op.Pet;
2844          
2845          // Create pet stat progress bars (using 100 as max for percentages)
2846          var healthBar = UI.CreateProgressBar("Health", (int)pet.Health, 100, 20);
2847          var fatigueBar = UI.CreateProgressBar("Fatigue", (int)pet.Fatigue, 100, 20);
2848          var injuryBar = UI.CreateProgressBar("Injury", (int)pet.Injury, 100, 20);
2849          var stressBar = UI.CreateProgressBar("Stress", (int)pet.Stress, 100, 20);
2850          var moraleBar = UI.CreateProgressBar("Morale", (int)pet.Morale, 100, 20);
2851          var hungerBar = UI.CreateProgressBar("Hunger", (int)pet.Hunger, 100, 20);
2852          var hydrationBar = UI.CreateProgressBar("Hydration", (int)pet.Hydration, 100, 20);
2853  
2854          var menuItems = new[] {
2855              "REST (Reduce Fatigue)",
2856              "EAT (Reduce Hunger)",
2857              "DRINK (Increase Hydration)",
2858              "BACK"
2859          };
2860  
2861          return new VStackWidget([
2862              UI.CreateBorder("PET ACTIONS"),
2863              new TextBlockWidget(""),
2864              UI.CreateBorder("PET STATUS", new VStackWidget([
2865                  new TextBlockWidget("  "),
2866                  healthBar,
2867                  fatigueBar,
2868                  injuryBar,
2869                  stressBar,
2870                  moraleBar,
2871                  hungerBar,
2872                  hydrationBar,
2873                  new TextBlockWidget("  ")
2874              ])),
2875              new TextBlockWidget(""),
2876              UI.CreateBorder("ACTIONS", new VStackWidget([
2877                  new TextBlockWidget("  Select an action:"),
2878                  new TextBlockWidget(""),
2879                  new ListWidget(menuItems).OnItemActivated(e => {
2880                      switch (e.ActivatedIndex)
2881                      {
2882                          case 0: // REST
2883                              ApplyPetAction("rest", hours: 8);
2884                              break;
2885                          case 1: // EAT
2886                              ApplyPetAction("eat", nutrition: 50);
2887                              break;
2888                          case 2: // DRINK
2889                              ApplyPetAction("drink", hydration: 50);
2890                              break;
2891                          case 3: // BACK
2892                              CurrentScreen = Screen.BaseCamp;
2893                              break;
2894                      }
2895                  })
2896              ])),
2897              UI.CreateStatusBar("Choose a pet action")
2898          ]);
2899      }
2900  
2901      void ApplyPetAction(string action, float? hours = null, float? nutrition = null, float? hydration = null)
2902      {
2903          try
2904          {
2905              var request = new
2906              {
2907                  Action = action,
2908                  Hours = hours,
2909                  Nutrition = nutrition,
2910                  Hydration = hydration
2911              };
2912  
2913              using var response = client.PostAsJsonAsync($"operators/{CurrentOperatorId}/pet", request, options)
2914                  .GetAwaiter().GetResult();
2915              
2916              if (!response.IsSuccessStatusCode)
2917              {
2918                  var errorContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
2919                  ErrorMessage = $"Failed to apply pet action: {response.StatusCode} - {errorContent}";
2920                  Message = $"Pet action failed.\nError: {ErrorMessage}\n\nPress OK to continue.";
2921                  CurrentScreen = Screen.Message;
2922                  ReturnScreen = Screen.PetActions;
2923                  return;
2924              }
2925  
2926              // Refresh operator state
2927              RefreshOperator();
2928              
2929              var actionText = action.ToUpperInvariant();
2930              Message = $"Pet action completed.\nAction: {actionText}\n\nPress OK to continue.";
2931              CurrentScreen = Screen.Message;
2932              ReturnScreen = Screen.PetActions;
2933          }
2934          catch (Exception ex)
2935          {
2936              ErrorMessage = ex.Message;
2937              Message = $"Error applying pet action: {ex.Message}\n\nPress OK to continue.";
2938              CurrentScreen = Screen.Message;
2939              ReturnScreen = Screen.PetActions;
2940          }
2941      }
2942  
2943      static OperatorState OperatorStateFromDto(OperatorDto dto)
2944      {
2945          PetState? pet = null;
2946          if (dto.Pet != null)
2947          {
2948              pet = new PetState
2949              {
2950                  Health = dto.Pet.Health,
2951                  Fatigue = dto.Pet.Fatigue,
2952                  Injury = dto.Pet.Injury,
2953                  Stress = dto.Pet.Stress,
2954                  Morale = dto.Pet.Morale,
2955                  Hunger = dto.Pet.Hunger,
2956                  Hydration = dto.Pet.Hydration,
2957                  LastUpdated = dto.Pet.LastUpdated
2958              };
2959          }
2960  
2961          return new OperatorState
2962          {
2963              Id = Guid.Parse(dto.Id),
2964              Name = dto.Name,
2965              TotalXp = dto.TotalXp,
2966              CurrentHealth = dto.CurrentHealth,
2967              MaxHealth = dto.MaxHealth,
2968              EquippedWeaponName = dto.EquippedWeaponName,
2969              UnlockedPerks = dto.UnlockedPerks,
2970              ExfilStreak = dto.ExfilStreak,
2971              IsDead = dto.IsDead,
2972              CurrentMode = dto.CurrentMode,
2973              ActiveCombatSessionId = dto.ActiveCombatSessionId,
2974              InfilSessionId = dto.InfilSessionId,
2975              InfilStartTime = dto.InfilStartTime,
2976              LockedLoadout = dto.LockedLoadout,
2977              Pet = pet
2978          };
2979      }
2980  
2981      static OperatorState ParseOperator(JsonElement json)
2982      {
2983          PetState? pet = null;
2984          if (json.TryGetProperty("pet", out var petJson) && petJson.ValueKind != JsonValueKind.Null)
2985          {
2986              pet = new PetState
2987              {
2988                  Health = petJson.GetProperty("health").GetSingle(),
2989                  Fatigue = petJson.GetProperty("fatigue").GetSingle(),
2990                  Injury = petJson.GetProperty("injury").GetSingle(),
2991                  Stress = petJson.GetProperty("stress").GetSingle(),
2992                  Morale = petJson.GetProperty("morale").GetSingle(),
2993                  Hunger = petJson.GetProperty("hunger").GetSingle(),
2994                  Hydration = petJson.GetProperty("hydration").GetSingle(),
2995                  LastUpdated = petJson.GetProperty("lastUpdated").GetDateTimeOffset()
2996              };
2997          }
2998  
2999          return new OperatorState
3000          {
3001              Id = json.GetProperty("id").GetGuid(),
3002              Name = json.GetProperty("name").GetString() ?? "",
3003              TotalXp = json.GetProperty("totalXp").GetInt64(),
3004              CurrentHealth = json.GetProperty("currentHealth").GetSingle(),
3005              MaxHealth = json.GetProperty("maxHealth").GetSingle(),
3006              EquippedWeaponName = json.GetProperty("equippedWeaponName").GetString() ?? "",
3007              UnlockedPerks = json.GetProperty("unlockedPerks").EnumerateArray().Select(p => p.GetString() ?? "").ToList(),
3008              ExfilStreak = json.GetProperty("exfilStreak").GetInt32(),
3009              IsDead = json.GetProperty("isDead").GetBoolean(),
3010              CurrentMode = json.GetProperty("currentMode").GetString() ?? "Base",
3011              InfilStartTime = json.TryGetProperty("infilStartTime", out var time) && time.ValueKind != JsonValueKind.Null 
3012                  ? time.GetDateTimeOffset() 
3013                  : null,
3014              InfilSessionId = json.TryGetProperty("infilSessionId", out var infil) && infil.ValueKind != JsonValueKind.Null 
3015                  ? infil.GetGuid() 
3016                  : null,
3017              ActiveCombatSessionId = json.TryGetProperty("activeCombatSessionId", out var combat) && combat.ValueKind != JsonValueKind.Null 
3018                  ? combat.GetGuid() 
3019                  : null,
3020              LockedLoadout = json.GetProperty("lockedLoadout").GetString() ?? "",
3021              Pet = pet
3022          };
3023      }
3024  
3025      static OperatorSummary ParseOperatorSummary(JsonElement json)
3026      {
3027          return new OperatorSummary
3028          {
3029              Id = json.GetProperty("id").GetGuid(),
3030              Name = json.GetProperty("name").GetString() ?? "",
3031              CurrentMode = json.GetProperty("currentMode").GetString() ?? "Base",
3032              IsDead = json.GetProperty("isDead").GetBoolean(),
3033              TotalXp = json.GetProperty("totalXp").GetInt64(),
3034              CurrentHealth = json.GetProperty("currentHealth").GetSingle(),
3035              MaxHealth = json.GetProperty("maxHealth").GetSingle()
3036          };
3037      }
3038  
3039      static CombatSessionDto? ParseSession(JsonElement json, JsonSerializerOptions jsonOptions)
3040      {
3041          // Deserialize directly from JsonElement
3042          return JsonSerializer.Deserialize<CombatSessionDto>(json.GetRawText(), jsonOptions);
3043      }
3044  
3045      /// <summary>
3046      /// Converts an Application-layer <see cref="GUNRPG.Application.Dtos.CombatSessionDto"/> to the
3047      /// console client's local <see cref="CombatSessionDto"/> by serializing with enum-as-string
3048      /// converters and then deserializing into the string-typed local model.
3049      /// </summary>
3050      CombatSessionDto ToLocalDto(GUNRPG.Application.Dtos.CombatSessionDto appDto)
3051      {
3052          var json = JsonSerializer.Serialize(appDto, options);
3053          return JsonSerializer.Deserialize<CombatSessionDto>(json, options)!;
3054      }
3055  }
3056  
3057  static class UI
3058  {
3059      /// <summary>
3060      /// Creates a bordered panel with a title.
3061      /// Uses Hex1b's BorderWidget to properly render box-drawing borders.
3062      /// </summary>
3063      public static Hex1bWidget CreateBorder(string title, Hex1bWidget? content = null)
3064      {
3065          // If no content provided, use a simple text block for spacing
3066          var borderContent = content ?? new TextBlockWidget("");
3067          
3068          // Use BorderWidget with Title() method (hex1b 0.83.0 API)
3069          return new BorderWidget(borderContent).Title(title);
3070      }
3071  
3072      /// <summary>
3073      /// Creates a simple status bar at the bottom of the screen.
3074      /// </summary>
3075      public static Hex1bWidget CreateStatusBar(string text)
3076      {
3077          return new TextBlockWidget($"  {text}");
3078      }
3079  
3080      /// <summary>
3081      /// Creates a visual progress bar widget with label.
3082      /// Uses hex1b's ProgressWidget for proper rendering.
3083      /// </summary>
3084      public static Hex1bWidget CreateProgressBar(string label, int current, int max, int width = 20)
3085      {
3086          // Calculate percentage for 0-100 range and clamp to valid range
3087          var percentage = max > 0 ? (int)((float)current / max * 100) : 0;
3088          percentage = Math.Clamp(percentage, 0, 100);
3089          
3090          var progressWidget = new ProgressWidget
3091          {
3092              Value = percentage
3093          };
3094          
3095          return new HStackWidget([
3096              new TextBlockWidget($"{label}: "),
3097              progressWidget.FixedWidth(width),
3098              new TextBlockWidget($" {current}/{max}")
3099          ]);
3100      }
3101  
3102      /// <summary>
3103      /// Creates a Pokemon Red-style battle log display widget.
3104      /// Shows the most recent battle events in a bordered dialog box.
3105      /// </summary>
3106      public static Hex1bWidget CreateBattleLogDisplay(List<BattleLogEntryDto>? battleLog)
3107      {
3108          if (battleLog == null || battleLog.Count == 0)
3109          {
3110              return CreateBorder("📋 BATTLE LOG", new VStackWidget([
3111                  new TextBlockWidget("  No events yet. Press ADVANCE TURN to begin combat."),
3112                  new TextBlockWidget("")
3113              ]));
3114          }
3115  
3116          // Take last 6 entries to fit in a reasonable display area
3117          var recentEntries = battleLog.TakeLast(6).ToList();
3118          
3119          var logWidgets = new List<Hex1bWidget>();
3120          foreach (var entry in recentEntries)
3121          {
3122              var actorPrefix = !string.IsNullOrEmpty(entry.ActorName) 
3123                  ? $"{entry.ActorName} " 
3124                  : "";
3125              
3126              // Format message in Pokemon style
3127              var message = $"  {actorPrefix}{entry.Message}";
3128              logWidgets.Add(new TextBlockWidget(message));
3129          }
3130          
3131          // Add empty line for spacing
3132          logWidgets.Add(new TextBlockWidget(""));
3133  
3134          return CreateBorder("📋 BATTLE LOG", new VStackWidget(logWidgets.ToArray()));
3135      }
3136  
3137      /// <summary>
3138      /// Creates an ASCII art visualization of the cover state.
3139      /// </summary>
3140      public static string CreateCoverVisual(string coverState)
3141      {
3142          return coverState?.ToUpper() switch
3143          {
3144              "NONE" => "COVER: [   EXPOSED   ]",
3145              "PARTIAL" => "COVER: [ ▄ PARTIAL ▄ ]",
3146              "FULL" => "COVER: [███  FULL  ███]",
3147              _ => $"COVER: {coverState}"
3148          };
3149      }
3150  }
3151  
3152  
3153  enum Screen
3154  {
3155      /// <summary>The user is not authenticated. Offers Login and Quit.</summary>
3156      LoginMenu,
3157      /// <summary>The device-code flow is in progress. Shows verification URL and user code.</summary>
3158      Authenticating,
3159      MainMenu,
3160      SelectOperator,
3161      CreateOperator,
3162      BaseCamp,
3163      StartMission,
3164      CombatSession,
3165      ExfilFailed,
3166      MissionComplete,
3167      Message,
3168      ChangeLoadout,
3169      TreatWounds,
3170      UnlockPerk,
3171      AbortMission,
3172      PetActions
3173  }
3174  
3175  enum RaidState
3176  {
3177      Infil,
3178      Combat,
3179      ExfilFailed,
3180      Completed
3181  }
3182  
3183  public static class RaidTimer
3184  {
3185      private const string AnsiReset = "\u001b[0m";
3186      private const string AnsiYellow = "\u001b[33m";
3187      private const string AnsiOrange = "\u001b[38;5;208m";
3188      private const string AnsiRed = "\u001b[31m";
3189      private const string AnsiBlink = "\u001b[5m";
3190  
3191      public static TimeSpan ComputeRemaining(DateTime raidStartTimeUtc, TimeSpan raidDuration, DateTime? nowUtc = null)
3192      {
3193          var now = nowUtc ?? DateTime.UtcNow;
3194          return raidDuration - (now - raidStartTimeUtc);
3195      }
3196  
3197      public static string FormatRemainingLabel(TimeSpan remaining)
3198      {
3199          var clamped = remaining <= TimeSpan.Zero ? TimeSpan.Zero : remaining;
3200          var formatted = $"EXFIL WINDOW: {clamped.Minutes:00}:{clamped.Seconds:00} REMAINING";
3201  
3202          if (clamped <= TimeSpan.FromSeconds(5))
3203              return $"🚨 {formatted}";
3204  
3205          if (clamped <= TimeSpan.FromSeconds(10))
3206              return $"⚠︎ {formatted}";
3207  
3208          if (clamped <= TimeSpan.FromSeconds(30))
3209              return $"⚠️ {formatted}";
3210  
3211          return formatted;
3212      }
3213  
3214      public static string FormatStyledRemainingLabel(TimeSpan remaining)
3215      {
3216          var label = FormatRemainingLabel(remaining);
3217          var clamped = remaining <= TimeSpan.Zero ? TimeSpan.Zero : remaining;
3218  
3219          if (clamped <= TimeSpan.FromSeconds(5))
3220              return $"{AnsiRed}{AnsiBlink}{label}{AnsiReset}";
3221  
3222          if (clamped <= TimeSpan.FromSeconds(10))
3223              return $"{AnsiOrange}{label}{AnsiReset}";
3224  
3225          if (clamped <= TimeSpan.FromSeconds(30))
3226              return $"{AnsiYellow}{label}{AnsiReset}";
3227  
3228          return label;
3229      }
3230  }