/ 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 }