< Summary

Line coverage
75%
Covered lines: 776
Uncovered lines: 246
Coverable lines: 1022
Total lines: 2016
Line coverage: 75.9%
Branch coverage
60%
Covered branches: 472
Total branches: 775
Branch coverage: 60.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: BuildRenderTree(...)0%156120%
File 2: .cctor()100%11100%
File 2: .ctor()100%11100%
File 2: OnInitializedAsync()50%6448%
File 2: <OnInitializedAsync()100%210%
File 2: get_IsRandomCharacterReady()100%11100%
File 2: get_HasUnprocessedLuckRunLoot()50%22100%
File 2: get_HasUnequippedOnPersonItems()100%22100%
File 2: get_HasEquippedWeapon()100%22100%
File 2: get_HasOverweightOnPersonItems()100%11100%
File 2: get_CanStartMainRaid()100%88100%
File 2: get_CanStartLuckRunRaid()50%44100%
File 2: get_RaidBlockReason()100%11100%
File 2: get_LuckRunBlockReason()0%2040%
File 2: get_CanStashOnPersonItem()100%210%
File 2: GetOnPersonEncumbrance()100%11100%
File 2: GetMainCharacterMaxEncumbrance()100%11100%
File 2: CanAddOnPersonItem(...)100%11100%
File 2: GetPreRaidEncumbranceText()100%11100%
File 2: GetRaidBlockReason()66.66%141275%
File 2: get_EquippedWeaponUsesAmmo()50%22100%
File 2: get_CurrentMagazineCapacity()50%22100%
File 2: get_EquippedWeaponSupportsSingleShot()50%22100%
File 2: get_EquippedWeaponSupportsBurstFire()50%22100%
File 2: get_EquippedWeaponSupportsFullAuto()50%22100%
File 2: get_CanAttack()100%11100%
File 2: get_CanAttackEnabled()50%22100%
File 2: get_CanBurstFire()100%11100%
File 2: get_CanBurstFireEnabled()50%22100%
File 2: get_CanFullAuto()100%11100%
File 2: get_CanFullAutoEnabled()50%22100%
File 2: get_CanReload()50%66100%
File 2: get_CanUseMedkit()100%210%
File 2: get_CurrentMedkits()0%620%
File 2: get_CurrentDiscoveredLoot()0%2040%
File 2: get_CurrentCarriedLoot()50%44100%
File 2: get_VisibleShopStock()100%11100%
File 2: get_CanReallocateStats()50%44100%
File 2: IsSlotType(...)100%22100%
File 2: FindEquippedIndexForSlot(...)0%2040%
File 2: ShouldEquipFromStash(...)0%620%
File 2: GetStashPrimaryActionLabel(...)0%620%
File 2: SellStashItemAsync()50%7671.42%
File 2: MoveStashToOnPersonAsync()66.66%6685.71%
File 2: SellOnPersonItemAsync()0%4260%
File 2: StashOnPersonItemAsync()0%4260%
File 2: EquipOnPersonItemAsync()0%4260%
File 2: UnequipOnPersonItemAsync()0%4260%
File 2: BuyFromShopAsync()100%44100%
File 2: AcceptStatsAsync()50%2275%
File 2: ReallocateStatsAsync()100%66100%
File 2: IncrementDraftStat(...)0%110100%
File 2: DecrementDraftStat(...)0%7280%
File 2: GetDraftStatValue(...)8.33%431240%
File 2: GetDraftModifier(...)100%210%
File 2: CanIncreaseDraftStat(...)62.5%88100%
File 2: CanDecreaseDraftStat(...)75%44100%
File 2: SetDraftStatValue(...)0%156120%
File 2: StartMainRaidAsync()50%2285.71%
File 2: StartRandomRaidAsync()50%2285.71%
File 2: StoreLuckRunItemAsync()0%7280%
File 2: MoveLuckRunItemToForRaidAsync()0%4260%
File 2: SellLuckRunItemAsync()50%9871.42%
File 2: SignOutAsync()100%210%
File 2: ExecuteProfileActionAsync()100%11100%
File 2: ExecuteRaidActionAsync()100%1162.5%
File 2: ApplyActionResult(...)100%66100%
File 2: ApplyProjectedState(...)97.61%424297.29%
File 2: ApplyPlayerProjection(...)100%1616100%
File 2: ApplyRaidProjection(...)93.85%11411496.89%
File 2: CreateRaidActionPayload(...)50%4487.5%
File 2: AddKnownLogCount(...)100%11100%
File 2: ClearRaidState()100%11100%
File 2: ReadItemList(...)0%620%
File 2: ReadItemListFromProperty(...)0%620%
File 2: ReadOnPersonEntries(...)0%620%
File 2: TryReadOnPersonEntries(...)91.66%121292.3%
File 2: TryReadRandomCharacter(...)90%1010100%
File 2: TryReadItemList(...)87.5%8888.88%
File 2: TryReadItem(...)76.78%5656100%
File 2: ApplyItemRulesProjection(...)0%506220%
File 2: TryReadPlayerStats(...)75%2424100%
File 2: ReadStringListFromProperty(...)50%8883.33%
File 2: TryGetProjection(...)100%66100%
File 2: TryGetInt32(...)80%101085.71%
File 2: TryGetBool(...)80%101087.5%
File 2: TryGetString(...)83.33%66100%
File 2: TryGetNullableDateTimeOffset(...)100%88100%
File 2: ExecuteLootActionAsync()100%11100%
File 2: GetRandomCooldownText()50%4483.33%
File 2: GetEquippedWeaponName()0%4260%
File 2: GetAmmoHudText()0%620%
File 2: GetEncounterTitle()0%3050%
File 2: AttackAsync()50%4475%
File 2: BurstFireAsync()0%2040%
File 2: FullAutoAsync()0%2040%
File 2: UseMedkitAsync()0%620%
File 2: ReloadAsync()50%2275%
File 2: FleeAsync()0%620%
File 2: TakeLootAsync(...)50%2266.66%
File 2: DropCarriedAsync(...)0%620%
File 2: DropEquippedAsync(...)0%620%
File 2: EquipFromDiscoveredAsync(...)0%620%
File 2: EquipFromCarriedAsync(...)0%620%
File 2: GetRaidEncumbrance()75%5466.66%
File 2: GetRaidMaxEncumbrance()75%5466.66%
File 2: GetRaidEncumbranceText()100%11100%
File 2: IsEquipableRaidItem(...)50%22100%
File 2: CanEquipRaidItem(...)43.75%441652%
File 2: GetEquippedItems()66.66%121285.71%
File 2: AttemptExtractAsync()100%22100%
File 2: GoDeeper()100%22100%
File 2: MoveTowardExtract()100%22100%
File 2: StartExtractHoldAsync()100%66100%
File 2: CancelExtractHoldAsync()50%7675%
File 2: ResolveExpiredExtractHoldAsync()50%8890%
File 2: IsExtractHoldEffectivelyActive()66.66%66100%
File 2: HasExpiredExtractHold()50%66100%
File 2: ReportHandledErrorAsync(...)100%11100%
File 2: GetLootSlotCount()0%620%
File 2: GetReallocateStatCost()100%11100%
File 2: GetReallocateStatCostLabel()100%210%
File 2: GetBuyPrice(...)100%210%
File 2: CanBuyItem(...)100%11100%
File 2: GetSellPrice(...)50%2266.66%
File 2: CanSellItem(...)100%210%
File 2: CanLootItem(...)83.33%6688.88%
File 2: EnsureMainCharacterHasWeaponFallback()100%44100%
File 2: NormalizeEquippedSlots()100%11100%
File 2: NormalizeEquippedSlot(...)87.5%8888.88%
File 2: ApplySnapshot(...)83.33%1212100%
File 2: ApplyActiveRaidSnapshot(...)65.38%262695.45%
File 2: Dispose()0%620%

File(s)

/home/runner/work/RaidLoop/RaidLoop/src/RaidLoop.Client/Pages/Home.razor

#LineLine coverage
 1@page "/"
 2@using RaidLoop.Client.Services
 3@inject IProfileApiClient Profiles
 4@inject IGameActionApiClient Actions
 5@inject IClientTelemetryService Telemetry
 6@inject SupabaseAuthService AuthService
 7
 8<PageTitle>RaidLoop</PageTitle>
 9
 10<style>
 11    .rarity-sellonly { color: #808080; }
 12    .rarity-common { color: #ffffff; }
 13    .rarity-uncommon { color: #1eff00; }
 14    .rarity-rare { color: #0070dd; }
 15    .rarity-epic { color: #ffd100; }
 16    .rarity-legendary { color: #ff8000; }
 17</style>
 18
 019@if (_isLoading)
 20{
 21    <p>Loading profiles...</p>
 22}
 023else if (!string.IsNullOrWhiteSpace(_loadErrorMessage))
 24{
 25    <section class="panel">
 26        <h2>Profile unavailable</h2>
 027        <p>@_loadErrorMessage</p>
 28        <button class="action" @onclick="SignOutAsync">Sign out</button>
 29    </section>
 30}
 031else if (!_inRaid)
 32{
 33    <section class="panel top-status-bar">
 034        @if (!string.IsNullOrWhiteSpace(_resultMessage))
 35        {
 036            <span class="status status-strip">@_resultMessage</span>
 37        }
 38        <div class="account-strip">
 039            @if (!string.IsNullOrWhiteSpace(AuthService.UserEmail))
 40            {
 041                <span><strong>Signed in:</strong> @AuthService.UserEmail</span>
 42            }
 43            <button class="action" @onclick="SignOutAsync">Sign out</button>
 44        </div>
 45    </section>
 46    <section class="panel">
 47        <div class="prep-header">
 48            <div class="prep-header-copy">
 49                <h2>Preparing for Raid</h2>
 50                <p>If your stash is wiped, you still get a knife run.</p>
 051                <p><strong>Money:</strong> $@_money | <strong>Storage:</strong> @_mainGame.Stash.Count/@MainStashCap</p>
 52            </div>
 53            <div class="prep-header-stats">
 54                <div class="stat-strip-inline">
 055                    <span class="stat-points-inline">Remaining: @_availableStatPoints</span>
 056                    @foreach (var statKey in StatOrder)
 57                    {
 58                        <div class="stat-column">
 059                            <button class="stat-adjust stat-adjust-up" disabled="@(!CanIncreaseDraftStat(statKey))" @onc
 60                            <div class="stat-readout">
 061                                <span class="stat-label">@statKey</span>
 062                                <span class="stat-value">@GetDraftStatValue(statKey)</span>
 63                            </div>
 064                            <button class="stat-adjust stat-adjust-down" disabled="@(!CanDecreaseDraftStat(statKey))" @o
 65                        </div>
 66                    }
 67                    <button class="action" disabled="@_statsAccepted" @onclick="AcceptStatsAsync">Accept Stats</button>
 068                    <button class="action" disabled="@(!CanReallocateStats)" @onclick="ReallocateStatsAsync">Re-Allocate
 69                </div>
 70            </div>
 71        </div>
 72        <div class="grid-two">
 73            <LoadoutPanel OnPersonItems="_onPersonItems" CanStashItem="CanStashOnPersonItem" CanStartRaid="CanStartMainR
 74            <StashPanel Items="_mainGame.Stash" GetPrimaryActionLabel="GetStashPrimaryActionLabel" CanSellItem="CanSellI
 75        </div>
 76    </section>
 77    <ShopPanel Stock="VisibleShopStock" Money="_money" CanBuyItem="CanBuyItem" CanPurchaseItem="CanAddOnPersonItem" GetB
 78    <PreRaidPanel RandomCharacter="_randomCharacter" IsRandomCharacterReady="IsRandomCharacterReady" CanStartLuckRunRaid
 79}
 80else
 81{
 82    <RaidHUD Raid="_raid!" MaxHealth="_maxHealth" AmmoHudText="@GetAmmoHudText()" Medkits="CurrentMedkits" LootSlots="Ge
 83}

/home/runner/work/RaidLoop/RaidLoop/src/RaidLoop.Client/Pages/Home.razor.cs

#LineLine coverage
 1using System.Net;
 2using System.Text.Json;
 3using RaidLoop.Client.Services;
 4using RaidLoop.Core;
 5using RaidLoop.Core.Contracts;
 6
 7namespace RaidLoop.Client.Pages;
 8
 9public partial class Home : IDisposable
 10{
 11    private const int FallbackKnifeItemDefId = 1;
 12    private const int MedkitItemDefId = 19;
 13    private const int MainStashCap = 30;
 114    private static readonly string[] StatOrder = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
 7015    private int _playerConstitution = 10;
 7016    private int _maxHealth = 30;
 17#if DEBUG
 18    private static readonly TimeSpan LuckRunCooldown = TimeSpan.FromSeconds(5);
 19#else
 120    private static readonly TimeSpan LuckRunCooldown = TimeSpan.FromMinutes(5);
 21#endif
 22
 7023    private GameState _mainGame = new([]);
 7024    private List<ShopStock> _shopStock = [];
 7025    private Dictionary<int, ItemRuleSnapshot> _itemRulesById = [];
 7026    private List<OnPersonEntry> _onPersonItems = [];
 27    private int _money;
 7028    private PlayerStats _acceptedStats = PlayerStats.Default;
 7029    private PlayerStats _draftStats = PlayerStats.Default;
 7030    private int _availableStatPoints = PlayerStatRules.StartingPool;
 31    private bool _statsAccepted;
 32
 33    private RandomCharacterState? _randomCharacter;
 7034    private DateTimeOffset _randomCharacterAvailableAt = DateTimeOffset.MinValue;
 35    private System.Threading.Timer? _clockTimer;
 36
 37    private RaidState? _raid;
 7038    private bool _isLoading = true;
 7039    private string _loadErrorMessage = string.Empty;
 40    private bool _inRaid;
 41    private bool _awaitingDecision;
 42    private int? _raidEncumbrance;
 43    private int? _raidMaxEncumbrance;
 44    private bool _extractHoldActive;
 45    private DateTimeOffset? _holdAtExtractUntil;
 46    private bool _extractHoldResolutionInFlight;
 7047    private EncounterType _encounterType = EncounterType.Neutral;
 7048    private string _encounterDescription = string.Empty;
 7049    private string _contactState = string.Empty;
 7050    private string _surpriseSide = string.Empty;
 7051    private string _initiativeWinner = string.Empty;
 52    private int _openingActionsRemaining;
 53    private bool _surprisePersistenceEligible;
 54
 7055    private string _enemyName = string.Empty;
 56    private int _enemyHealth;
 57    private int _enemyDexterity;
 58    private int _enemyConstitution;
 59    private int _enemyStrength;
 60
 7061    private string _lootContainer = string.Empty;
 162    private static readonly List<Item> EmptyItems = [];
 63
 64    private int _ammo;
 65    private int _challenge;
 66    private int _distanceFromExtract;
 7067    private string _resultMessage = string.Empty;
 7068    private string _activeRaidId = string.Empty;
 7069    private readonly List<string> _log = [];
 70
 71    protected override async Task OnInitializedAsync()
 72    {
 73        try
 74        {
 275            var response = await Profiles.BootstrapAsync();
 076            ApplySnapshot(response.Snapshot);
 077            NormalizeEquippedSlots();
 078            EnsureMainCharacterHasWeaponFallback();
 079            _loadErrorMessage = string.Empty;
 080        }
 281        catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
 82        {
 183            await ReportHandledErrorAsync("Profile bootstrap failed due to unauthorized session.", "bootstrap", ex);
 184            await AuthService.SignOutAsync();
 185            _isLoading = false;
 186            return;
 87        }
 188        catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.BadGa
 89        {
 190            await ReportHandledErrorAsync("Profile bootstrap failed because the profile service is unavailable.", "boots
 191            _loadErrorMessage = "The profile service is temporarily unavailable. Try again in a moment.";
 192            _isLoading = false;
 193            return;
 94        }
 095        catch (Exception ex)
 96        {
 097            await ReportHandledErrorAsync("Profile bootstrap failed.", "bootstrap", ex);
 098            _loadErrorMessage = "The profile could not be loaded right now. Refresh and try again.";
 099            _isLoading = false;
 0100            return;
 101        }
 102
 0103        _clockTimer = new System.Threading.Timer(async _ =>
 0104        {
 0105            await InvokeAsync(async () =>
 0106            {
 0107                await ResolveExpiredExtractHoldAsync();
 0108                StateHasChanged();
 0109            });
 0110        }, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
 111
 0112        _isLoading = false;
 2113    }
 114
 2115    private bool IsRandomCharacterReady => DateTimeOffset.UtcNow >= _randomCharacterAvailableAt;
 7116    private bool HasUnprocessedLuckRunLoot => _randomCharacter is not null && _randomCharacter.Inventory.Count > 0;
 117
 27118    private bool HasUnequippedOnPersonItems => _onPersonItems.Any(x => IsSlotType(x.Item.Type) && !x.IsEquipped);
 24119    private bool HasEquippedWeapon => _onPersonItems.Any(x => x.IsEquipped && x.Item.Type == ItemType.Weapon);
 5120    private bool HasOverweightOnPersonItems => GetOnPersonEncumbrance() > GetMainCharacterMaxEncumbrance();
 5121    private bool CanStartMainRaid => _statsAccepted && !HasUnprocessedLuckRunLoot && !HasUnequippedOnPersonItems && !Has
 2122    private bool CanStartLuckRunRaid => _statsAccepted && !HasUnprocessedLuckRunLoot && IsRandomCharacterReady;
 1123    private string? RaidBlockReason => GetRaidBlockReason();
 0124    private string? LuckRunBlockReason => HasUnprocessedLuckRunLoot
 0125        ? "Luck Run loot must be sold, stored, or moved to For Raid before entering a raid."
 0126        : !_statsAccepted
 0127            ? "Accept Stats before entering a raid."
 0128        : null;
 0129    private bool CanStashOnPersonItem => _mainGame.Stash.Count < MainStashCap;
 130    private int GetOnPersonEncumbrance()
 131    {
 77132        return CombatBalance.GetTotalEncumbrance(_onPersonItems.Select(entry => entry.Item));
 133    }
 134
 135    private int GetMainCharacterMaxEncumbrance()
 136    {
 10137        return CombatBalance.GetMaxEncumbranceFromStrength(_acceptedStats.Strength);
 138    }
 139
 140    private bool CanAddOnPersonItem(Item item)
 141    {
 4142        return GetOnPersonEncumbrance() + Math.Max(0, item.Weight) <= GetMainCharacterMaxEncumbrance();
 143    }
 144
 145    private string GetPreRaidEncumbranceText()
 146    {
 1147        return $"{GetOnPersonEncumbrance()}/{GetMainCharacterMaxEncumbrance()} lbs";
 148    }
 149
 150    private string? GetRaidBlockReason()
 151    {
 1152        var reasons = new List<string>();
 153
 1154        if (HasUnprocessedLuckRunLoot)
 155        {
 0156            reasons.Add("Luck Run loot must be sold, stored, or moved to For Raid before entering a raid.");
 157        }
 158
 1159        if (!_statsAccepted)
 160        {
 0161            reasons.Add("Accept Stats before entering a raid.");
 162        }
 163
 1164        if (HasUnequippedOnPersonItems)
 165        {
 0166            reasons.Add("You need to move your unequipped items to stash or sell them.");
 167        }
 168
 1169        if (HasOverweightOnPersonItems)
 170        {
 1171            reasons.Add($"Your loadout weight is too high. {GetPreRaidEncumbranceText()}");
 172        }
 173
 1174        if (!HasEquippedWeapon)
 175        {
 1176            reasons.Add("You don't have a weapon equipped.");
 177        }
 178
 1179        return reasons.Count == 0 ? null : string.Join(" ", reasons);
 180    }
 181
 10182    private bool EquippedWeaponUsesAmmo => CombatBalance.WeaponUsesAmmo(_raid?.Inventory.EquippedWeapon);
 2183    private int CurrentMagazineCapacity => CombatBalance.GetMagazineCapacity(_raid?.Inventory.EquippedWeapon);
 2184    private bool EquippedWeaponSupportsSingleShot => CombatBalance.SupportsSingleShot(_raid?.Inventory.EquippedWeapon);
 2185    private bool EquippedWeaponSupportsBurstFire => CombatBalance.SupportsBurstFire(_raid?.Inventory.EquippedWeapon);
 2186    private bool EquippedWeaponSupportsFullAuto => CombatBalance.SupportsFullAuto(_raid?.Inventory.EquippedWeapon);
 2187    private bool CanAttack => EquippedWeaponSupportsSingleShot;
 2188    private bool CanAttackEnabled => !EquippedWeaponUsesAmmo || _ammo > 0;
 2189    private bool CanBurstFire => EquippedWeaponSupportsBurstFire;
 3190    private bool CanBurstFireEnabled => EquippedWeaponUsesAmmo && _ammo >= 3;
 2191    private bool CanFullAuto => EquippedWeaponSupportsFullAuto;
 4192    private bool CanFullAutoEnabled => EquippedWeaponUsesAmmo && _ammo >= 10;
 1193    private bool CanReload => _raid is not null && EquippedWeaponUsesAmmo && CurrentMagazineCapacity > 0 && _ammo < Curr
 0194    private bool CanUseMedkit => CurrentMedkits > 0;
 0195    private int CurrentMedkits => _raid?.Inventory.MedkitCount ?? 0;
 0196    private List<Item> CurrentDiscoveredLoot => _raid?.Inventory.DiscoveredLoot ?? EmptyItems;
 3197    private List<Item> CurrentCarriedLoot => _raid?.Inventory.CarriedItems ?? EmptyItems;
 9198    private IReadOnlyList<ShopStock> VisibleShopStock => _shopStock.Where(stock => CanBuyItem(stock.Item)).ToList();
 1199    private bool CanReallocateStats => _raid is null && _statsAccepted && _money >= GetReallocateStatCost();
 200
 201    private static bool IsSlotType(ItemType type)
 202    {
 22203        return type is ItemType.Weapon or ItemType.Armor or ItemType.Backpack;
 204    }
 205
 206    private int? FindEquippedIndexForSlot(ItemType slotType)
 207    {
 0208        var idx = _onPersonItems.FindIndex(x => x.IsEquipped && x.Item.Type == slotType);
 0209        return idx >= 0 ? idx : null;
 210    }
 211
 212    private bool ShouldEquipFromStash(Item item)
 213    {
 0214        return IsSlotType(item.Type) && FindEquippedIndexForSlot(item.Type) is null;
 215    }
 216
 217    private string GetStashPrimaryActionLabel(Item item)
 218    {
 0219        return ShouldEquipFromStash(item) ? "Equip" : "For Raid";
 220    }
 221
 222    private async Task SellStashItemAsync(int stashIndex)
 223    {
 3224        if (stashIndex < 0 || stashIndex >= _mainGame.Stash.Count)
 225        {
 0226            return;
 227        }
 228
 3229        var item = _mainGame.Stash[stashIndex];
 3230        if (GetSellPrice(item) <= 0)
 231        {
 0232            return;
 233        }
 234
 3235        await ExecuteProfileActionAsync("sell-stash-item", new { stashIndex });
 2236    }
 237
 238    private async Task MoveStashToOnPersonAsync(int stashIndex)
 239    {
 2240        if (stashIndex < 0 || stashIndex >= _mainGame.Stash.Count)
 241        {
 0242            return;
 243        }
 244
 2245        var item = _mainGame.Stash[stashIndex];
 2246        if (!CanAddOnPersonItem(item))
 247        {
 1248            return;
 249        }
 250
 1251        await ExecuteProfileActionAsync("move-stash-to-on-person", new { stashIndex });
 2252    }
 253
 254    private async Task SellOnPersonItemAsync(int onPersonIndex)
 255    {
 0256        if (onPersonIndex < 0 || onPersonIndex >= _onPersonItems.Count)
 257        {
 0258            return;
 259        }
 260
 0261        var item = _onPersonItems[onPersonIndex].Item;
 0262        if (GetSellPrice(item) <= 0)
 263        {
 0264            return;
 265        }
 266
 0267        await ExecuteProfileActionAsync("sell-on-person-item", new { onPersonIndex });
 0268    }
 269
 270    private async Task StashOnPersonItemAsync(int onPersonIndex)
 271    {
 0272        if (!CanStashOnPersonItem || onPersonIndex < 0 || onPersonIndex >= _onPersonItems.Count)
 273        {
 0274            return;
 275        }
 276
 0277        await ExecuteProfileActionAsync("stash-on-person-item", new { onPersonIndex });
 0278    }
 279
 280    private async Task EquipOnPersonItemAsync(int onPersonIndex)
 281    {
 0282        if (onPersonIndex < 0 || onPersonIndex >= _onPersonItems.Count)
 283        {
 0284            return;
 285        }
 286
 0287        var selected = _onPersonItems[onPersonIndex];
 0288        if (!IsSlotType(selected.Item.Type))
 289        {
 0290            return;
 291        }
 292
 0293        await ExecuteProfileActionAsync("equip-on-person-item", new { onPersonIndex });
 0294    }
 295
 296    private async Task UnequipOnPersonItemAsync(int onPersonIndex)
 297    {
 0298        if (onPersonIndex < 0 || onPersonIndex >= _onPersonItems.Count)
 299        {
 0300            return;
 301        }
 302
 0303        var current = _onPersonItems[onPersonIndex];
 0304        if (!IsSlotType(current.Item.Type))
 305        {
 0306            return;
 307        }
 308
 0309        await ExecuteProfileActionAsync("unequip-on-person-item", new { onPersonIndex });
 0310    }
 311
 312    private async Task BuyFromShopAsync(ShopStock stock)
 313    {
 2314        var price = stock.Price;
 2315        if (_money < price || !CanAddOnPersonItem(stock.Item))
 316        {
 1317            return;
 318        }
 319
 1320        await ExecuteProfileActionAsync("buy-from-shop", new { itemDefId = stock.ItemDefId });
 2321    }
 322
 323    private async Task AcceptStatsAsync()
 324    {
 1325        if (_statsAccepted)
 326        {
 0327            return;
 328        }
 329
 1330        await ExecuteProfileActionAsync("accept-stats", new { draftStats = _draftStats });
 1331    }
 332
 333    private async Task ReallocateStatsAsync()
 334    {
 2335        if (_raid is not null || !_statsAccepted || _money < GetReallocateStatCost())
 336        {
 1337            return;
 338        }
 339
 1340        await ExecuteProfileActionAsync("reallocate-stats", new { });
 2341    }
 342
 343    private void IncrementDraftStat(string statKey)
 344    {
 0345        if (_raid is not null || _statsAccepted)
 346        {
 0347            return;
 348        }
 349
 0350        var current = GetDraftStatValue(statKey);
 0351        var cost = PlayerStatRules.GetRaiseCost(current);
 0352        if (current >= PlayerStatRules.MaximumScore || cost <= 0 || _availableStatPoints < cost)
 353        {
 0354            return;
 355        }
 356
 0357        SetDraftStatValue(statKey, current + 1);
 0358        _availableStatPoints -= cost;
 0359    }
 360
 361    private void DecrementDraftStat(string statKey)
 362    {
 0363        if (_raid is not null || _statsAccepted)
 364        {
 0365            return;
 366        }
 367
 0368        var current = GetDraftStatValue(statKey);
 0369        var refund = PlayerStatRules.GetLowerRefund(current);
 0370        if (current <= PlayerStatRules.MinimumScore || refund <= 0)
 371        {
 0372            return;
 373        }
 374
 0375        SetDraftStatValue(statKey, current - 1);
 0376        _availableStatPoints += refund;
 0377    }
 378
 379    private int GetDraftStatValue(string statKey)
 380    {
 1381        return statKey switch
 1382        {
 1383            "STR" => _draftStats.Strength,
 0384            "DEX" => _draftStats.Dexterity,
 0385            "CON" => _draftStats.Constitution,
 0386            "INT" => _draftStats.Intelligence,
 0387            "WIS" => _draftStats.Wisdom,
 0388            "CHA" => _draftStats.Charisma,
 0389            _ => PlayerStatRules.MinimumScore
 1390        };
 391    }
 392
 393    private int GetDraftModifier(string statKey)
 394    {
 0395        return PlayerStatRules.GetAbilityModifier(GetDraftStatValue(statKey));
 396    }
 397
 398    private bool CanIncreaseDraftStat(string statKey)
 399    {
 1400        var current = GetDraftStatValue(statKey);
 1401        var cost = PlayerStatRules.GetRaiseCost(current);
 1402        return _raid is null
 1403            && !_statsAccepted
 1404            && current < PlayerStatRules.MaximumScore
 1405            && cost > 0
 1406            && _availableStatPoints >= cost;
 407    }
 408
 409    private bool CanDecreaseDraftStat(string statKey)
 410    {
 1411        return _raid is null
 1412            && !_statsAccepted
 1413            && GetDraftStatValue(statKey) > PlayerStatRules.MinimumScore;
 414    }
 415
 416    private void SetDraftStatValue(string statKey, int value)
 417    {
 0418        _draftStats = statKey switch
 0419        {
 0420            "STR" => _draftStats with { Strength = value },
 0421            "DEX" => _draftStats with { Dexterity = value },
 0422            "CON" => _draftStats with { Constitution = value },
 0423            "INT" => _draftStats with { Intelligence = value },
 0424            "WIS" => _draftStats with { Wisdom = value },
 0425            "CHA" => _draftStats with { Charisma = value },
 0426            _ => _draftStats
 0427        };
 0428    }
 429
 430    private async Task StartMainRaidAsync()
 431    {
 3432        if (!CanStartMainRaid)
 433        {
 0434            return;
 435        }
 436
 3437        _activeRaidId = Guid.NewGuid().ToString("N");
 3438        GameEventLog.Clear();
 3439        GameEventLog.SetRaidContext(_activeRaidId);
 3440        await ExecuteRaidActionAsync("start-main-raid", new { });
 3441    }
 442
 443    private async Task StartRandomRaidAsync()
 444    {
 2445        if (!CanStartLuckRunRaid)
 446        {
 0447            return;
 448        }
 449
 2450        _activeRaidId = Guid.NewGuid().ToString("N");
 2451        GameEventLog.Clear();
 2452        GameEventLog.SetRaidContext(_activeRaidId);
 2453        await ExecuteRaidActionAsync("start-random-raid", new { });
 2454    }
 455
 456    private async Task StoreLuckRunItemAsync(int luckIndex)
 457    {
 0458        if (_randomCharacter is null || luckIndex < 0 || luckIndex >= _randomCharacter.Inventory.Count)
 459        {
 0460            return;
 461        }
 462
 0463        if (_mainGame.Stash.Count >= MainStashCap)
 464        {
 0465            return;
 466        }
 467
 0468        await ExecuteProfileActionAsync("store-luck-run-item", new { luckIndex });
 0469    }
 470
 471    private async Task MoveLuckRunItemToForRaidAsync(int luckIndex)
 472    {
 0473        if (_randomCharacter is null || luckIndex < 0 || luckIndex >= _randomCharacter.Inventory.Count)
 474        {
 0475            return;
 476        }
 477
 0478        await ExecuteProfileActionAsync("move-luck-run-item-to-on-person", new { luckIndex });
 0479    }
 480
 481    private async Task SellLuckRunItemAsync(int luckIndex)
 482    {
 1483        if (_randomCharacter is null || luckIndex < 0 || luckIndex >= _randomCharacter.Inventory.Count)
 484        {
 0485            return;
 486        }
 487
 1488        var item = _randomCharacter.Inventory[luckIndex];
 1489        if (GetSellPrice(item) <= 0)
 490        {
 0491            return;
 492        }
 493
 1494        await ExecuteProfileActionAsync("sell-luck-run-item", new { luckIndex });
 1495    }
 496
 497    private async Task SignOutAsync()
 498    {
 0499        await AuthService.SignOutAsync();
 0500    }
 501
 502    private async Task ExecuteProfileActionAsync(string action, object payload)
 503    {
 504        try
 505        {
 8506            var result = await Actions.SendAsync(action, payload);
 7507            ApplyActionResult(result);
 7508            NormalizeEquippedSlots();
 7509            EnsureMainCharacterHasWeaponFallback();
 7510        }
 1511        catch (Exception ex)
 512        {
 1513            await ReportHandledErrorAsync($"Profile action '{action}' failed.", "profile-action", ex, new { action, payl
 1514            throw;
 515        }
 7516    }
 517
 518    private async Task ExecuteRaidActionAsync(string action, object payload)
 519    {
 17520        var raidPayload = CreateRaidActionPayload(payload);
 521
 522        try
 523        {
 17524            var result = await Actions.SendAsync(action, raidPayload);
 17525            ApplyActionResult(result);
 17526        }
 0527        catch (Exception ex)
 528        {
 0529            await ReportHandledErrorAsync($"Raid action '{action}' failed.", "raid-action", ex, new { action, payload = 
 0530            throw;
 531        }
 17532    }
 533
 534    private void ApplyActionResult(GameActionResult result)
 535    {
 41536        if (result.Projections is { ValueKind: JsonValueKind.Object } projections)
 537        {
 39538            ApplyProjectedState(projections);
 539        }
 540
 41541        if (!string.IsNullOrWhiteSpace(result.Message))
 542        {
 12543            _resultMessage = result.Message;
 544        }
 41545    }
 546
 547    private void ApplyProjectedState(JsonElement projections)
 548    {
 39549        var updatedStash = false;
 39550        var updatedLoadout = false;
 551
 39552        if (TryGetProjection(projections, "economy", out var economy)
 39553            && TryGetInt32(economy, "money", out var money))
 554        {
 11555            _money = money;
 556        }
 557
 39558        if (TryGetProjection(projections, "stash", out var stash)
 39559            && TryGetProjection(stash, "mainStash", out var mainStash))
 560        {
 12561            if (TryReadItemList(mainStash, out var parsedStash))
 562            {
 11563                _mainGame = new GameState(parsedStash);
 11564                updatedStash = true;
 565            }
 566        }
 567
 39568        if (TryGetProjection(projections, "loadout", out var loadout)
 39569            && TryGetProjection(loadout, "onPersonItems", out var onPersonItems))
 570        {
 13571            if (TryReadOnPersonEntries(onPersonItems, out var parsedOnPersonItems))
 572            {
 12573                _onPersonItems = parsedOnPersonItems;
 12574                updatedLoadout = true;
 575            }
 576        }
 577
 39578        if (TryGetProjection(projections, "player", out var playerProjection))
 579        {
 8580            ApplyPlayerProjection(playerProjection);
 581        }
 582
 39583        if (TryGetProjection(projections, "luckRun", out var luckRun))
 584        {
 6585            if (TryGetString(luckRun, "randomCharacterAvailableAt", out var availableAtText)
 6586                && DateTimeOffset.TryParse(availableAtText, out var availableAt))
 587            {
 5588                _randomCharacterAvailableAt = availableAt;
 589            }
 590
 6591            if (TryGetProjection(luckRun, "randomCharacter", out var randomCharacter))
 592            {
 6593                if (randomCharacter.ValueKind == JsonValueKind.Null)
 594                {
 1595                    _randomCharacter = null;
 596                }
 5597                else if (TryReadRandomCharacter(randomCharacter, out var parsedRandomCharacter))
 598                {
 4599                    _randomCharacter = parsedRandomCharacter;
 600                }
 601
 6602                if (_randomCharacter is not null && _randomCharacter.Inventory.Count == 0)
 603                {
 0604                    _randomCharacter = null;
 605                }
 606            }
 607        }
 608
 39609        if (TryGetProjection(projections, "raid", out var raid))
 610        {
 28611            if (raid.ValueKind == JsonValueKind.Null)
 612            {
 1613                ClearRaidState();
 614            }
 615            else
 616            {
 27617                ApplyRaidProjection(raid);
 618            }
 619        }
 620
 39621        if (updatedStash || updatedLoadout)
 622        {
 12623            EnsureMainCharacterHasWeaponFallback();
 624        }
 625
 39626        if (updatedLoadout)
 627        {
 12628            NormalizeEquippedSlots();
 629        }
 39630    }
 631
 632    private void ApplyPlayerProjection(JsonElement playerProjection)
 633    {
 8634        if (TryGetProjection(playerProjection, "acceptedStats", out var acceptedStats)
 8635            && TryReadPlayerStats(acceptedStats, out var parsedAcceptedStats))
 636        {
 8637            _acceptedStats = parsedAcceptedStats;
 8638            _playerConstitution = parsedAcceptedStats.Constitution;
 639        }
 640
 8641        if (TryGetInt32(playerProjection, "playerConstitution", out var playerConstitution))
 642        {
 2643            _playerConstitution = playerConstitution;
 644        }
 645
 8646        if (TryGetInt32(playerProjection, "playerMaxHealth", out var playerMaxHealth))
 647        {
 2648            _maxHealth = playerMaxHealth;
 649        }
 650
 8651        if (TryGetProjection(playerProjection, "draftStats", out var draftStats)
 8652            && TryReadPlayerStats(draftStats, out var parsedDraftStats))
 653        {
 7654            _draftStats = parsedDraftStats;
 655        }
 656
 8657        if (TryGetInt32(playerProjection, "availableStatPoints", out var availableStatPoints))
 658        {
 4659            _availableStatPoints = availableStatPoints;
 660        }
 661
 8662        if (TryGetBool(playerProjection, "statsAccepted", out var statsAccepted))
 663        {
 4664            _statsAccepted = statsAccepted;
 665        }
 8666    }
 667
 668    private void ApplyRaidProjection(JsonElement raid)
 669    {
 27670        var freshRaid = _raid is null;
 27671        var raidEncumbranceProjected = false;
 27672        var raidMaxEncumbranceProjected = false;
 27673        var inventoryChanged = false;
 27674        if (freshRaid)
 675        {
 7676            _raidEncumbrance = null;
 7677            _raidMaxEncumbrance = null;
 7678            _extractHoldActive = false;
 7679            _holdAtExtractUntil = null;
 7680            _extractHoldResolutionInFlight = false;
 7681            _raid = new RaidState(_maxHealth, new RaidInventory());
 7682            _inRaid = true;
 7683            _awaitingDecision = false;
 7684            _challenge = 0;
 7685            _distanceFromExtract = 3;
 7686            _ammo = 0;
 7687            _encounterDescription = string.Empty;
 7688            _contactState = string.Empty;
 7689            _surpriseSide = string.Empty;
 7690            _initiativeWinner = string.Empty;
 7691            _openingActionsRemaining = 0;
 7692            _surprisePersistenceEligible = false;
 7693            _enemyName = string.Empty;
 7694            _enemyHealth = 0;
 7695            _enemyDexterity = 0;
 7696            _enemyConstitution = 0;
 7697            _enemyStrength = 0;
 7698            _lootContainer = string.Empty;
 7699            _log.Clear();
 7700            _encounterType = EncounterType.Neutral;
 701        }
 702
 27703        var raidState = _raid!;
 27704        var inventory = raidState.Inventory;
 27705        var health = raidState.Health;
 27706        var backpackCapacity = raidState.BackpackCapacity;
 27707        var hasRaidPatch = freshRaid;
 27708        var holdProjectionSeen = false;
 27709        var parsedExtractHoldActive = false;
 27710        DateTimeOffset? parsedHoldAtExtractUntil = null;
 27711        var extractHoldActiveSeen = false;
 27712        var holdAtExtractUntilSeen = false;
 27713        var carriedLootProjected = false;
 27714        var parsedCarriedMedkits = 0;
 27715        var medkitsProjected = false;
 27716        var parsedMedkits = inventory.MedkitCount;
 717
 27718        if (TryGetInt32(raid, "health", out var parsedHealth))
 719        {
 21720            health = parsedHealth;
 21721            hasRaidPatch = true;
 722        }
 723
 27724        if (TryGetInt32(raid, "backpackCapacity", out var parsedBackpackCapacity))
 725        {
 8726            backpackCapacity = parsedBackpackCapacity;
 8727            hasRaidPatch = true;
 728        }
 729
 27730        if (TryGetInt32(raid, "encumbrance", out var parsedEncumbrance))
 731        {
 8732            _raidEncumbrance = parsedEncumbrance;
 8733            raidEncumbranceProjected = true;
 8734            hasRaidPatch = true;
 735        }
 736
 27737        if (TryGetInt32(raid, "maxEncumbrance", out var parsedMaxEncumbrance))
 738        {
 8739            _raidMaxEncumbrance = parsedMaxEncumbrance;
 8740            raidState.MaxEncumbrance = parsedMaxEncumbrance;
 8741            raidMaxEncumbranceProjected = true;
 8742            hasRaidPatch = true;
 743        }
 744
 27745        if (TryGetBool(raid, "extractHoldActive", out var extractHoldActive))
 746        {
 5747            parsedExtractHoldActive = extractHoldActive;
 5748            extractHoldActiveSeen = true;
 5749            holdProjectionSeen = true;
 5750            hasRaidPatch = true;
 751        }
 752
 27753        if (TryGetNullableDateTimeOffset(raid, "holdAtExtractUntil", out var holdAtExtractUntil))
 754        {
 4755            parsedHoldAtExtractUntil = holdAtExtractUntil;
 4756            holdAtExtractUntilSeen = true;
 4757            holdProjectionSeen = true;
 4758            hasRaidPatch = true;
 759        }
 760
 27761        if (holdProjectionSeen)
 762        {
 5763            _extractHoldActive = extractHoldActiveSeen ? parsedExtractHoldActive : false;
 5764            _holdAtExtractUntil = holdAtExtractUntilSeen ? parsedHoldAtExtractUntil : null;
 765        }
 766
 27767        if (TryGetProjection(raid, "equippedItems", out var equippedItems))
 768        {
 18769            if (TryReadItemList(equippedItems, out var items))
 770            {
 30771                inventory.EquippedWeapon = items.FirstOrDefault(item => item.Type == ItemType.Weapon);
 41772                inventory.EquippedArmor = items.FirstOrDefault(item => item.Type == ItemType.Armor);
 41773                inventory.EquippedBackpack = items.FirstOrDefault(item => item.Type == ItemType.Backpack);
 16774                inventoryChanged = true;
 16775                hasRaidPatch = true;
 776            }
 777        }
 778
 27779        if (TryGetProjection(raid, "carriedLoot", out var carriedLoot))
 780        {
 20781            if (TryReadItemList(carriedLoot, out var items))
 782            {
 19783                inventory.CarriedItems.Clear();
 19784                parsedCarriedMedkits = 0;
 46785                foreach (var item in items)
 786                {
 4787                    if (CombatBalance.IsMedkit(item))
 788                    {
 2789                        parsedCarriedMedkits++;
 2790                        continue;
 791                    }
 792
 2793                    inventory.CarriedItems.Add(item);
 794                }
 19795                carriedLootProjected = true;
 19796                inventoryChanged = true;
 19797                hasRaidPatch = true;
 798            }
 799        }
 800
 27801        if (TryGetProjection(raid, "discoveredLoot", out var discoveredLoot))
 802        {
 19803            if (TryReadItemList(discoveredLoot, out var items))
 804            {
 18805                inventory.DiscoveredLoot.Clear();
 18806                inventory.DiscoveredLoot.AddRange(items);
 18807                inventoryChanged = true;
 18808                hasRaidPatch = true;
 809            }
 810        }
 811
 27812        if (TryGetInt32(raid, "medkits", out var medkits))
 813        {
 8814            parsedMedkits = medkits;
 8815            medkitsProjected = true;
 8816            inventoryChanged = true;
 8817            hasRaidPatch = true;
 818        }
 819
 27820        if (medkitsProjected)
 821        {
 8822            inventory.MedkitCount = Math.Max(parsedMedkits, parsedCarriedMedkits);
 823        }
 19824        else if (carriedLootProjected)
 825        {
 11826            inventory.MedkitCount = parsedCarriedMedkits;
 827        }
 828
 27829        if (TryGetInt32(raid, "ammo", out var ammo))
 830        {
 18831            _ammo = ammo;
 18832            hasRaidPatch = true;
 833        }
 834
 27835        if (TryGetInt32(raid, "challenge", out var challenge))
 836        {
 13837            _challenge = challenge;
 13838            hasRaidPatch = true;
 839        }
 840
 27841        if (TryGetInt32(raid, "distanceFromExtract", out var distanceFromExtract))
 842        {
 13843            _distanceFromExtract = distanceFromExtract;
 13844            hasRaidPatch = true;
 845        }
 846
 27847        if (TryGetBool(raid, "awaitingDecision", out var awaitingDecision))
 848        {
 16849            _awaitingDecision = awaitingDecision;
 16850            hasRaidPatch = true;
 851        }
 852
 27853        string? encounterDescription = null;
 27854        string? encounterDescriptionKey = null;
 27855        if (TryGetString(raid, "encounterDescription", out var encounterDescriptionText))
 856        {
 16857            encounterDescription = encounterDescriptionText;
 16858            hasRaidPatch = true;
 859        }
 27860        if (TryGetString(raid, "encounterDescriptionKey", out var encounterDescriptionKeyText))
 861        {
 0862            encounterDescriptionKey = encounterDescriptionKeyText;
 0863            hasRaidPatch = true;
 864        }
 865
 27866        if (TryGetString(raid, "contactState", out var contactState))
 867        {
 15868            _contactState = string.IsNullOrWhiteSpace(contactState) ? string.Empty : contactState;
 15869            hasRaidPatch = true;
 870        }
 871
 27872        if (TryGetString(raid, "surpriseSide", out var surpriseSide))
 873        {
 15874            _surpriseSide = string.IsNullOrWhiteSpace(surpriseSide) ? string.Empty : surpriseSide;
 15875            hasRaidPatch = true;
 876        }
 877
 27878        if (TryGetString(raid, "initiativeWinner", out var initiativeWinner))
 879        {
 15880            _initiativeWinner = string.IsNullOrWhiteSpace(initiativeWinner) ? string.Empty : initiativeWinner;
 15881            hasRaidPatch = true;
 882        }
 883
 27884        if (TryGetInt32(raid, "openingActionsRemaining", out var openingActionsRemaining))
 885        {
 15886            _openingActionsRemaining = openingActionsRemaining;
 15887            hasRaidPatch = true;
 888        }
 889
 27890        if (TryGetBool(raid, "surprisePersistenceEligible", out var surprisePersistenceEligible))
 891        {
 15892            _surprisePersistenceEligible = surprisePersistenceEligible;
 15893            hasRaidPatch = true;
 894        }
 895
 27896        string? enemyName = null;
 27897        string? enemyKey = null;
 27898        if (TryGetString(raid, "enemyName", out var enemyNameText))
 899        {
 16900            enemyName = enemyNameText;
 16901            hasRaidPatch = true;
 902        }
 27903        if (TryGetString(raid, "enemyKey", out var enemyKeyText))
 904        {
 0905            enemyKey = enemyKeyText;
 0906            hasRaidPatch = true;
 907        }
 908
 27909        if (TryGetInt32(raid, "enemyHealth", out var enemyHealth))
 910        {
 19911            _enemyHealth = enemyHealth;
 19912            hasRaidPatch = true;
 913        }
 914
 27915        if (TryGetInt32(raid, "enemyDexterity", out var enemyDexterity))
 916        {
 0917            _enemyDexterity = enemyDexterity;
 0918            hasRaidPatch = true;
 919        }
 920
 27921        if (TryGetInt32(raid, "enemyConstitution", out var enemyConstitution))
 922        {
 1923            _enemyConstitution = enemyConstitution;
 1924            hasRaidPatch = true;
 925        }
 926
 27927        if (TryGetInt32(raid, "enemyStrength", out var enemyStrength))
 928        {
 1929            _enemyStrength = enemyStrength;
 1930            hasRaidPatch = true;
 931        }
 932
 27933        if (TryGetString(raid, "lootContainer", out var lootContainer))
 934        {
 16935            _lootContainer = lootContainer;
 16936            hasRaidPatch = true;
 937        }
 938
 27939        if (TryGetString(raid, "encounterType", out var encounterTypeText)
 27940            && Enum.TryParse<EncounterType>(encounterTypeText, ignoreCase: true, out var encounterType))
 941        {
 16942            _encounterType = encounterType;
 16943            hasRaidPatch = true;
 944        }
 945
 27946        if (encounterDescription is not null)
 947        {
 16948            _encounterDescription = RaidPresentationCatalog.GetEncounterDescription(encounterDescriptionKey, _encounterT
 949        }
 950
 27951        if (enemyName is not null || enemyKey is not null)
 952        {
 16953            _enemyName = RaidPresentationCatalog.GetEnemyLabel(enemyKey, enemyName);
 954        }
 955
 27956        if (TryGetProjection(raid, "logEntriesAdded", out var logEntriesAdded))
 957        {
 1958            if (logEntriesAdded.ValueKind == JsonValueKind.Array)
 959            {
 1960                _log.AddRange(ReadStringListFromProperty(raid, "logEntriesAdded").Select(RaidPresentationCatalog.Localiz
 1961                hasRaidPatch = true;
 962            }
 963        }
 26964        else if (TryGetProjection(raid, "logEntries", out var logEntries))
 965        {
 18966            if (logEntries.ValueKind == JsonValueKind.Array)
 967            {
 18968                _log.Clear();
 18969                _log.AddRange(ReadStringListFromProperty(raid, "logEntries").Select(RaidPresentationCatalog.LocalizeLogE
 18970                hasRaidPatch = true;
 971            }
 972        }
 973
 27974        inventory.BackpackCapacity = backpackCapacity;
 27975        raidState.Health = health;
 27976        raidState.BackpackCapacity = backpackCapacity;
 977
 27978        if (inventoryChanged && !raidEncumbranceProjected)
 979        {
 13980            _raidEncumbrance = null;
 981        }
 982
 27983        if (inventoryChanged && !raidMaxEncumbranceProjected)
 984        {
 13985            _raidMaxEncumbrance = null;
 986        }
 987
 27988        if (!freshRaid && hasRaidPatch)
 989        {
 20990            _raid = raidState;
 991        }
 992
 27993        if (freshRaid || hasRaidPatch)
 994        {
 27995            _inRaid = true;
 996        }
 27997    }
 998
 999    private object CreateRaidActionPayload(object payload)
 1000    {
 171001        return payload switch
 171002        {
 01003            null => new { knownLogCount = _log.Count },
 171004            _ => JsonSerializer.Deserialize<Dictionary<string, object?>>(
 171005                JsonSerializer.Serialize(payload)) is { } values
 171006                ? AddKnownLogCount(values)
 171007                : new Dictionary<string, object?> { ["knownLogCount"] = _log.Count }
 171008        };
 1009    }
 1010
 1011    private Dictionary<string, object?> AddKnownLogCount(Dictionary<string, object?> values)
 1012    {
 171013        values["knownLogCount"] = _log.Count;
 171014        return values;
 1015    }
 1016
 1017    private void ClearRaidState()
 1018    {
 11019        _raid = null;
 11020        _raidEncumbrance = null;
 11021        _raidMaxEncumbrance = null;
 11022        _extractHoldActive = false;
 11023        _holdAtExtractUntil = null;
 11024        _extractHoldResolutionInFlight = false;
 11025        _inRaid = false;
 11026        _awaitingDecision = false;
 11027        _challenge = 0;
 11028        _distanceFromExtract = 0;
 11029        _ammo = 0;
 11030        _encounterType = EncounterType.Neutral;
 11031        _encounterDescription = string.Empty;
 11032        _contactState = string.Empty;
 11033        _surpriseSide = string.Empty;
 11034        _initiativeWinner = string.Empty;
 11035        _openingActionsRemaining = 0;
 11036        _surprisePersistenceEligible = false;
 11037        _enemyName = string.Empty;
 11038        _enemyHealth = 0;
 11039        _enemyDexterity = 0;
 11040        _enemyConstitution = 0;
 11041        _enemyStrength = 0;
 11042        _lootContainer = string.Empty;
 11043        _log.Clear();
 11044    }
 1045
 1046    private static List<Item> ReadItemList(JsonElement items)
 1047    {
 01048        return TryReadItemList(items, out var parsedItems) ? parsedItems : [];
 1049    }
 1050
 1051    private static List<Item> ReadItemListFromProperty(JsonElement parent, string propertyName)
 1052    {
 01053        return TryGetProjection(parent, propertyName, out var items) ? ReadItemList(items) : [];
 1054    }
 1055
 1056    private static List<OnPersonEntry> ReadOnPersonEntries(JsonElement onPersonItems)
 1057    {
 01058        return TryReadOnPersonEntries(onPersonItems, out var parsedEntries) ? parsedEntries : [];
 1059    }
 1060
 1061    private static bool TryReadOnPersonEntries(JsonElement onPersonItems, out List<OnPersonEntry> entries)
 1062    {
 131063        entries = [];
 131064        if (onPersonItems.ValueKind != JsonValueKind.Array)
 1065        {
 01066            return false;
 1067        }
 1068
 131069        var hasValidEntry = false;
 461070        foreach (var entry in onPersonItems.EnumerateArray())
 1071        {
 101072            if (!TryGetProjection(entry, "item", out var itemElement)
 101073                && !TryGetProjection(entry, "Item", out itemElement))
 1074            {
 1075                continue;
 1076            }
 1077
 91078            if (!TryReadItem(itemElement, out var parsedItem))
 1079            {
 1080                continue;
 1081            }
 1082
 91083            entries.Add(new OnPersonEntry(
 91084                parsedItem,
 91085                TryGetBool(entry, "isEquipped", out var isEquipped) && isEquipped));
 91086            hasValidEntry = true;
 1087        }
 1088
 131089        return hasValidEntry || onPersonItems.GetArrayLength() == 0;
 1090    }
 1091
 1092    private static bool TryReadRandomCharacter(JsonElement randomCharacter, out RandomCharacterState parsedRandomCharact
 1093    {
 61094        parsedRandomCharacter = null!;
 61095        var name = TryGetString(randomCharacter, "name", out var randomCharacterName)
 61096            ? randomCharacterName
 61097            : string.Empty;
 61098        var inventory = TryGetProjection(randomCharacter, "inventory", out var inventoryItems)
 61099            && TryReadItemList(inventoryItems, out var parsedInventory)
 61100            ? parsedInventory
 61101            : [];
 61102        if (!TryGetProjection(randomCharacter, "stats", out var statsElement) || !TryReadPlayerStats(statsElement, out v
 1103        {
 11104            return false;
 1105        }
 1106
 51107        parsedRandomCharacter = new RandomCharacterState(name, inventory, parsedStats);
 51108        return true;
 1109    }
 1110
 1111    private static bool TryReadItemList(JsonElement items, out List<Item> parsedItems)
 1112    {
 751113        parsedItems = [];
 751114        if (items.ValueKind != JsonValueKind.Array)
 1115        {
 01116            return false;
 1117        }
 1118
 751119        var hasValidItem = false;
 2421120        foreach (var entry in items.EnumerateArray())
 1121        {
 461122            if (!TryReadItem(entry, out var parsedItem))
 1123            {
 1124                continue;
 1125            }
 1126
 391127            parsedItems.Add(parsedItem);
 391128            hasValidItem = true;
 1129        }
 1130
 751131        return hasValidItem || items.GetArrayLength() == 0;
 1132    }
 1133
 1134    private static bool TryReadItem(JsonElement item, out Item parsedItem)
 1135    {
 591136        var hasItemDefId = TryGetInt32(item, "itemDefId", out var itemDefId) && itemDefId > 0;
 591137        var name = TryGetString(item, "name", out var itemName)
 591138            ? itemName
 591139            : TryGetString(item, "Name", out var itemNameUpperCase)
 591140                ? itemNameUpperCase
 591141                : string.Empty;
 1142
 591143        if (hasItemDefId
 591144            && ItemCatalog.TryGetByItemDefId(itemDefId, out var catalogItemById)
 591145            && catalogItemById is not null)
 1146        {
 461147            parsedItem = catalogItemById;
 461148            return true;
 1149        }
 1150
 131151        if (!hasItemDefId && string.IsNullOrWhiteSpace(name))
 1152        {
 41153            parsedItem = default!;
 41154            return false;
 1155        }
 1156
 91157        var type = TryGetInt32(item, "type", out var parsedType) && Enum.IsDefined(typeof(ItemType), parsedType)
 91158            ? (ItemType)parsedType
 91159            : TryGetInt32(item, "Type", out var parsedTypeUpperCase) && Enum.IsDefined(typeof(ItemType), parsedTypeUpper
 91160                ? (ItemType)parsedTypeUpperCase
 91161            : ItemType.Sellable;
 91162        var value = TryGetInt32(item, "value", out var parsedValue)
 91163            ? parsedValue
 91164            : TryGetInt32(item, "Value", out var parsedValueUpperCase)
 91165                ? parsedValueUpperCase
 91166                : 1;
 91167        if (!TryGetInt32(item, "weight", out var parsedWeight)
 91168            && !TryGetInt32(item, "Weight", out parsedWeight))
 1169        {
 41170            parsedItem = default!;
 41171            return false;
 1172        }
 51173        var slots = TryGetInt32(item, "slots", out var parsedSlots)
 51174            ? parsedSlots
 51175            : TryGetInt32(item, "Slots", out var parsedSlotsUpperCase)
 51176                ? parsedSlotsUpperCase
 51177                : 1;
 51178        var rarity = TryGetInt32(item, "rarity", out var parsedRarity) && Enum.IsDefined(typeof(Rarity), parsedRarity)
 51179            ? (Rarity)parsedRarity
 51180            : TryGetInt32(item, "Rarity", out var parsedRarityUpperCase) && Enum.IsDefined(typeof(Rarity), parsedRarityU
 51181                ? (Rarity)parsedRarityUpperCase
 51182            : Rarity.Common;
 51183        var displayRarity = TryGetInt32(item, "displayRarity", out var parsedDisplayRarity) && Enum.IsDefined(typeof(Dis
 51184            ? (DisplayRarity)parsedDisplayRarity
 51185            : TryGetInt32(item, "DisplayRarity", out var parsedDisplayRarityUpperCase) && Enum.IsDefined(typeof(DisplayR
 51186                ? (DisplayRarity)parsedDisplayRarityUpperCase
 51187            : DisplayRarity.Common;
 1188
 51189        parsedItem = new Item(string.IsNullOrWhiteSpace(name) ? itemDefId.ToString() : name, type, parsedWeight, value, 
 51190        {
 51191            ItemDefId = hasItemDefId ? itemDefId : 0
 51192        };
 51193        return true;
 1194    }
 1195
 1196    private void ApplyItemRulesProjection(JsonElement itemRules)
 1197    {
 01198        if (itemRules.ValueKind != JsonValueKind.Array)
 1199        {
 01200            return;
 1201        }
 1202
 01203        var updatedRules = new Dictionary<int, ItemRuleSnapshot>();
 01204        foreach (var rule in itemRules.EnumerateArray())
 1205        {
 01206            if (!TryGetInt32(rule, "itemDefId", out var itemDefId) || itemDefId <= 0)
 1207            {
 1208                continue;
 1209            }
 1210
 01211            var type = TryGetInt32(rule, "type", out var parsedType) && Enum.IsDefined(typeof(ItemType), parsedType)
 01212                ? (ItemType)parsedType
 01213                : ItemType.Sellable;
 01214            var weight = TryGetInt32(rule, "weight", out var parsedWeight) ? parsedWeight : 0;
 01215            var slots = TryGetInt32(rule, "slots", out var parsedSlots) ? parsedSlots : 1;
 01216            var rarity = TryGetInt32(rule, "rarity", out var parsedRarity) && Enum.IsDefined(typeof(Rarity), parsedRarit
 01217                ? (Rarity)parsedRarity
 01218                : Rarity.Common;
 1219
 01220            updatedRules[itemDefId] = new ItemRuleSnapshot(itemDefId, type, weight, slots, rarity);
 1221        }
 1222
 01223        if (updatedRules.Count > 0)
 1224        {
 01225            _itemRulesById = updatedRules;
 1226        }
 01227    }
 1228
 1229    private static bool TryReadPlayerStats(JsonElement statsElement, out PlayerStats parsedStats)
 1230    {
 201231        var strength = TryGetInt32(statsElement, "strength", out var parsedStrength)
 201232            ? parsedStrength
 201233            : TryGetInt32(statsElement, "Strength", out var parsedStrengthUpper)
 201234                ? parsedStrengthUpper
 201235                : PlayerStatRules.MinimumScore;
 201236        var dexterity = TryGetInt32(statsElement, "dexterity", out var parsedDexterity)
 201237            ? parsedDexterity
 201238            : TryGetInt32(statsElement, "Dexterity", out var parsedDexterityUpper)
 201239                ? parsedDexterityUpper
 201240                : PlayerStatRules.MinimumScore;
 201241        var constitution = TryGetInt32(statsElement, "constitution", out var parsedConstitution)
 201242            ? parsedConstitution
 201243            : TryGetInt32(statsElement, "Constitution", out var parsedConstitutionUpper)
 201244                ? parsedConstitutionUpper
 201245                : PlayerStatRules.MinimumScore;
 201246        var intelligence = TryGetInt32(statsElement, "intelligence", out var parsedIntelligence)
 201247            ? parsedIntelligence
 201248            : TryGetInt32(statsElement, "Intelligence", out var parsedIntelligenceUpper)
 201249                ? parsedIntelligenceUpper
 201250                : PlayerStatRules.MinimumScore;
 201251        var wisdom = TryGetInt32(statsElement, "wisdom", out var parsedWisdom)
 201252            ? parsedWisdom
 201253            : TryGetInt32(statsElement, "Wisdom", out var parsedWisdomUpper)
 201254                ? parsedWisdomUpper
 201255                : PlayerStatRules.MinimumScore;
 201256        var charisma = TryGetInt32(statsElement, "charisma", out var parsedCharisma)
 201257            ? parsedCharisma
 201258            : TryGetInt32(statsElement, "Charisma", out var parsedCharismaUpper)
 201259                ? parsedCharismaUpper
 201260                : PlayerStatRules.MinimumScore;
 1261
 201262        parsedStats = new PlayerStats(strength, dexterity, constitution, intelligence, wisdom, charisma);
 201263        return true;
 1264    }
 1265
 1266    private static List<string> ReadStringListFromProperty(JsonElement parent, string propertyName)
 1267    {
 191268        if (!TryGetProjection(parent, propertyName, out var items) || items.ValueKind != JsonValueKind.Array)
 1269        {
 01270            return [];
 1271        }
 1272
 191273        return items.EnumerateArray()
 211274            .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : string.Empty)
 211275            .Where(item => !string.IsNullOrWhiteSpace(item))
 191276            .ToList();
 1277    }
 1278
 1279    private static bool TryGetProjection(JsonElement parent, string propertyName, out JsonElement value)
 1280    {
 16001281        if (parent.ValueKind == JsonValueKind.Object)
 1282        {
 233291283            foreach (var property in parent.EnumerateObject())
 1284            {
 105391285                if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
 1286                {
 7571287                    value = property.Value;
 7571288                    return true;
 1289                }
 1290            }
 1291        }
 1292
 8431293        value = default;
 8431294        return false;
 7571295    }
 1296
 1297    private static bool TryGetInt32(JsonElement parent, string propertyName, out int value)
 1298    {
 6641299        if (TryGetProjection(parent, propertyName, out var property))
 1300        {
 3221301            if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out value))
 1302            {
 3061303                return true;
 1304            }
 1305
 161306            if (property.ValueKind == JsonValueKind.String && int.TryParse(property.GetString(), out value))
 1307            {
 01308                return true;
 1309            }
 1310        }
 1311
 3581312        value = default;
 3581313        return false;
 1314    }
 1315
 1316    private static bool TryGetBool(JsonElement parent, string propertyName, out bool value)
 1317    {
 981318        if (TryGetProjection(parent, propertyName, out var property))
 1319        {
 531320            if (property.ValueKind is JsonValueKind.True or JsonValueKind.False)
 1321            {
 491322                value = property.GetBoolean();
 491323                return true;
 1324            }
 1325
 41326            if (property.ValueKind == JsonValueKind.String && bool.TryParse(property.GetString(), out value))
 1327            {
 01328                return true;
 1329            }
 1330        }
 1331
 491332        value = default;
 491333        return false;
 1334    }
 1335
 1336    private static bool TryGetString(JsonElement parent, string propertyName, out string value)
 1337    {
 3541338        if (TryGetProjection(parent, propertyName, out var property) && property.ValueKind == JsonValueKind.String)
 1339        {
 1391340            value = property.GetString() ?? string.Empty;
 1391341            return true;
 1342        }
 1343
 2151344        value = string.Empty;
 2151345        return false;
 1346    }
 1347
 1348    private static bool TryGetNullableDateTimeOffset(JsonElement parent, string propertyName, out DateTimeOffset? value)
 1349    {
 271350        if (TryGetProjection(parent, propertyName, out var property))
 1351        {
 41352            if (property.ValueKind == JsonValueKind.Null)
 1353            {
 21354                value = null;
 21355                return true;
 1356            }
 1357
 21358            if (property.ValueKind == JsonValueKind.String
 21359                && DateTimeOffset.TryParse(property.GetString(), out var parsed))
 1360            {
 21361                value = parsed;
 21362                return true;
 1363            }
 1364        }
 1365
 231366        value = default;
 231367        return false;
 1368    }
 1369
 1370    private async Task ExecuteLootActionAsync(string action, Item item, string eventName)
 1371    {
 21372        await ExecuteRaidActionAsync(action, new { itemDefId = item.ItemDefId });
 21373        GameEventLog.Append(new GameEvent(
 21374            eventName,
 21375            _activeRaidId,
 21376            GameEventLog.CreateItemSnapshots([item]),
 21377            DateTimeOffset.UtcNow));
 21378    }
 1379
 1380    private string GetRandomCooldownText()
 1381    {
 11382        var left = _randomCharacterAvailableAt - DateTimeOffset.UtcNow;
 11383        if (left < TimeSpan.Zero)
 1384        {
 01385            left = TimeSpan.Zero;
 1386        }
 1387
 11388        return left.TotalHours >= 1
 11389            ? $"{(int)left.TotalHours}:{left.Minutes:D2}:{left.Seconds:D2}"
 11390            : $"{left.Minutes:D2}:{left.Seconds:D2}";
 1391    }
 1392
 1393    private string GetEquippedWeaponName()
 1394    {
 01395        var weapon = _raid?.Inventory.EquippedWeapon;
 01396        return ItemPresentationCatalog.GetLabel(weapon) is { Length: > 0 } label
 01397            ? label
 01398            : ItemPresentationCatalog.GetLabel(ItemCatalog.GetByItemDefId(FallbackKnifeItemDefId));
 1399    }
 1400
 1401    private string GetAmmoHudText()
 1402    {
 01403        if (!EquippedWeaponUsesAmmo)
 1404        {
 01405            return "Ammo: âˆž";
 1406        }
 1407
 01408        return $"Ammo: {_ammo} / {CurrentMagazineCapacity}";
 1409    }
 1410
 1411    private string GetEncounterTitle()
 1412    {
 01413        return _encounterType switch
 01414        {
 01415            EncounterType.Combat => "Combat Encounter",
 01416            EncounterType.Loot => "Loot Encounter",
 01417            EncounterType.Neutral => "Area Clear",
 01418            EncounterType.Extraction => "Extraction Opportunity",
 01419            _ => "Encounter"
 01420        };
 1421    }
 1422
 1423    private async Task AttackAsync()
 1424    {
 21425        if (_raid is null || _encounterType != EncounterType.Combat)
 1426        {
 01427            return;
 1428        }
 21429        await ExecuteRaidActionAsync("attack", new { target = "enemy" });
 21430    }
 1431
 1432    private async Task BurstFireAsync()
 1433    {
 01434        if (_raid is null || _encounterType != EncounterType.Combat)
 1435        {
 01436            return;
 1437        }
 01438        await ExecuteRaidActionAsync("burst-fire", new { target = "enemy" });
 01439    }
 1440
 1441    private async Task FullAutoAsync()
 1442    {
 01443        if (_raid is null || _encounterType != EncounterType.Combat)
 1444        {
 01445            return;
 1446        }
 1447
 01448        await ExecuteRaidActionAsync("full-auto", new { target = "enemy" });
 01449    }
 1450
 1451    private async Task UseMedkitAsync()
 1452    {
 01453        if (_raid is null)
 1454        {
 01455            return;
 1456        }
 01457        await ExecuteRaidActionAsync("use-medkit", new { });
 01458    }
 1459
 1460    private async Task ReloadAsync()
 1461    {
 11462        if (!CanReload)
 1463        {
 01464            return;
 1465        }
 11466        await ExecuteRaidActionAsync("reload", new { });
 11467    }
 1468
 1469    private async Task FleeAsync()
 1470    {
 01471        if (_encounterType != EncounterType.Combat)
 1472        {
 01473            return;
 1474        }
 01475        await ExecuteRaidActionAsync("flee", new { });
 01476    }
 1477
 1478
 1479    private Task TakeLootAsync(Item lootItem)
 1480    {
 21481        if (_raid is null)
 1482        {
 01483            return Task.CompletedTask;
 1484        }
 21485        return ExecuteLootActionAsync("take-loot", lootItem, "loot.acquired");
 1486    }
 1487
 1488    private Task DropCarriedAsync(Item item)
 1489    {
 01490        if (_raid is null)
 1491        {
 01492            return Task.CompletedTask;
 1493        }
 01494        return ExecuteRaidActionAsync("drop-carried", new { itemDefId = item.ItemDefId });
 1495    }
 1496
 1497    private Task DropEquippedAsync(ItemType slotType)
 1498    {
 01499        if (_raid is null)
 1500        {
 01501            return Task.CompletedTask;
 1502        }
 01503        return ExecuteRaidActionAsync("drop-equipped", new { slotType = slotType.ToString() });
 1504    }
 1505
 1506    private Task EquipFromDiscoveredAsync(Item item)
 1507    {
 01508        if (_raid is null)
 1509        {
 01510            return Task.CompletedTask;
 1511        }
 01512        return ExecuteLootActionAsync("equip-from-discovered", item, "player.equip");
 1513    }
 1514
 1515    private Task EquipFromCarriedAsync(Item item)
 1516    {
 01517        if (_raid is null)
 1518        {
 01519            return Task.CompletedTask;
 1520        }
 01521        return ExecuteLootActionAsync("equip-from-carried", item, "player.equip");
 1522    }
 1523
 1524    private int GetRaidEncumbrance()
 1525    {
 121526        if (_raid is null)
 1527        {
 01528            return 0;
 1529        }
 1530
 121531        return _raidEncumbrance ?? CombatBalance.GetTotalEncumbrance(_raid.Inventory.GetExtractableItems());
 1532    }
 1533
 1534    private int GetRaidMaxEncumbrance()
 1535    {
 121536        if (_raid is null)
 1537        {
 01538            return 0;
 1539        }
 1540
 121541        return _raidMaxEncumbrance ?? _raid.MaxEncumbrance;
 1542    }
 1543
 1544    private string GetRaidEncumbranceText()
 1545    {
 51546        return $"{GetRaidEncumbrance()}/{GetRaidMaxEncumbrance()} lbs";
 1547    }
 1548
 1549    private static bool IsEquipableRaidItem(Item item)
 1550    {
 31551        return item.Type is ItemType.Weapon or ItemType.Armor or ItemType.Backpack;
 1552    }
 1553
 1554    private bool CanEquipRaidItem(Item item)
 1555    {
 31556        if (_raid is null || !IsEquipableRaidItem(item))
 1557        {
 01558            return false;
 1559        }
 1560
 31561        var carriedItems = CurrentCarriedLoot.ToList();
 41562        var carriedIndex = carriedItems.FindIndex(current => ReferenceEquals(current, item));
 31563        var itemWasCarried = carriedIndex >= 0;
 31564        if (carriedIndex >= 0)
 1565        {
 01566            carriedItems.RemoveAt(carriedIndex);
 1567        }
 31568        var currentEncumbrance = GetRaidEncumbrance();
 31569        var projectedEncumbrance = currentEncumbrance;
 91570        var replacedItem = GetEquippedItems().FirstOrDefault(existing => existing.Type == item.Type);
 31571        if (replacedItem is not null)
 1572        {
 01573            projectedEncumbrance -= Math.Max(0, replacedItem.Weight);
 1574        }
 1575
 31576        if (item.Type == ItemType.Backpack)
 1577        {
 01578            var backpackCapacity = CombatBalance.GetBackpackCapacity(item);
 01579            var currentSlots = carriedItems.Sum(x => x.Slots);
 01580            var spilledWeight = 0;
 01581            while (currentSlots > backpackCapacity && carriedItems.Count > 0)
 1582            {
 01583                var spill = carriedItems[^1];
 01584                carriedItems.RemoveAt(carriedItems.Count - 1);
 01585                currentSlots -= spill.Slots;
 01586                spilledWeight += Math.Max(0, spill.Weight);
 1587            }
 1588
 01589            projectedEncumbrance -= spilledWeight;
 1590        }
 1591
 31592        if (!itemWasCarried)
 1593        {
 31594            projectedEncumbrance += Math.Max(0, item.Weight);
 1595        }
 1596
 31597        return projectedEncumbrance <= GetRaidMaxEncumbrance();
 1598    }
 1599
 1600    private IEnumerable<Item> GetEquippedItems()
 1601    {
 31602        if (_raid?.Inventory.EquippedWeapon is not null)
 1603        {
 31604            yield return _raid.Inventory.EquippedWeapon;
 1605        }
 1606
 31607        if (_raid?.Inventory.EquippedArmor is not null)
 1608        {
 01609            yield return _raid.Inventory.EquippedArmor;
 1610        }
 1611
 31612        if (_raid?.Inventory.EquippedBackpack is not null)
 1613        {
 31614            yield return _raid.Inventory.EquippedBackpack;
 1615        }
 31616    }
 1617
 1618    private async Task AttemptExtractAsync()
 1619    {
 21620        if (_raid is null)
 1621        {
 11622            return;
 1623        }
 11624        await ExecuteRaidActionAsync("attempt-extract", new { });
 21625    }
 1626
 1627    private async Task GoDeeper()
 1628    {
 21629        if (_raid is null)
 1630        {
 11631            return;
 1632        }
 1633
 11634        await ExecuteRaidActionAsync("go-deeper", new { });
 21635    }
 1636
 1637    private async Task MoveTowardExtract()
 1638    {
 21639        if (_raid is null)
 1640        {
 11641            return;
 1642        }
 1643
 11644        await ExecuteRaidActionAsync("move-toward-extract", new { });
 21645    }
 1646
 1647    private async Task StartExtractHoldAsync()
 1648    {
 41649        if (_raid is null || IsExtractHoldEffectivelyActive() || _extractHoldResolutionInFlight)
 1650        {
 21651            return;
 1652        }
 1653
 21654        await ExecuteRaidActionAsync("start-extract-hold", new { });
 41655    }
 1656
 1657    private async Task CancelExtractHoldAsync()
 1658    {
 11659        if (_raid is null || !_extractHoldActive || _extractHoldResolutionInFlight)
 1660        {
 01661            return;
 1662        }
 1663
 11664        await ExecuteRaidActionAsync("cancel-extract-hold", new { });
 11665    }
 1666
 1667    private async Task ResolveExpiredExtractHoldAsync()
 1668    {
 11669        if (_raid is null || !HasExpiredExtractHold() || _extractHoldResolutionInFlight)
 1670        {
 01671            return;
 1672        }
 1673
 11674        _extractHoldResolutionInFlight = true;
 1675        try
 1676        {
 11677            await ExecuteRaidActionAsync("resolve-extract-hold", new
 11678            {
 11679                holdAtExtractUntil = _holdAtExtractUntil?.ToString("O")
 11680            });
 11681        }
 1682        finally
 1683        {
 11684            _extractHoldResolutionInFlight = false;
 1685        }
 11686    }
 1687
 1688    private bool IsExtractHoldEffectivelyActive()
 1689    {
 41690        if (!_extractHoldActive)
 1691        {
 21692            return false;
 1693        }
 1694
 21695        return _holdAtExtractUntil is null || _holdAtExtractUntil > DateTimeOffset.UtcNow;
 1696    }
 1697
 1698    private bool HasExpiredExtractHold()
 1699    {
 11700        return _extractHoldActive
 11701            && _holdAtExtractUntil is not null
 11702            && _holdAtExtractUntil <= DateTimeOffset.UtcNow;
 1703    }
 1704
 1705    private ValueTask ReportHandledErrorAsync(string message, string source, Exception exception, object? context = null
 1706    {
 31707        return Telemetry.ReportErrorAsync(
 31708            message,
 31709            new
 31710            {
 31711                source,
 31712                exception = exception.GetType().FullName,
 31713                exceptionMessage = exception.Message,
 31714                stack = exception.ToString(),
 31715                context
 31716            });
 1717    }
 1718
 1719    private int GetLootSlotCount()
 1720    {
 01721        return _raid?.Inventory.CarriedItems.Sum(x => x.Slots) ?? 0;
 1722    }
 1723
 1724    private int GetReallocateStatCost()
 1725    {
 21726        return (int)Math.Round(_money / 2m, MidpointRounding.AwayFromZero);
 1727    }
 1728
 1729    private string GetReallocateStatCostLabel()
 1730    {
 01731        return $"${GetReallocateStatCost()}";
 1732    }
 1733
 1734    private int GetBuyPrice(Item item)
 1735    {
 01736        return CombatBalance.GetShopPrice(
 01737            CombatBalance.GetBuyPrice(item),
 01738            CombatBalance.GetCharismaModifier(_acceptedStats.Charisma),
 01739            isBuying: true);
 1740    }
 1741
 1742    private bool CanBuyItem(Item item)
 1743    {
 81744        return item.Rarity <= CombatBalance.GetMaxShopRarityFromChaBonus(
 81745            CombatBalance.GetCharismaModifier(_acceptedStats.Charisma));
 1746    }
 1747
 1748    private int GetSellPrice(Item item)
 1749    {
 41750        if (item.ItemDefId == FallbackKnifeItemDefId)
 1751        {
 01752            return 0;
 1753        }
 1754
 41755        return item.Value;
 1756    }
 1757
 1758    private bool CanSellItem(Item item)
 1759    {
 01760        return GetSellPrice(item) > 0;
 1761    }
 1762
 1763    private bool CanLootItem(Item item)
 1764    {
 41765        if (_raid is null)
 1766        {
 01767            return false;
 1768        }
 1769
 41770        var currentEncumbrance = GetRaidEncumbrance();
 41771        if (currentEncumbrance + Math.Max(0, item.Weight) > GetRaidMaxEncumbrance())
 1772        {
 21773            return false;
 1774        }
 1775
 21776        if (CombatBalance.IsMedkit(item))
 1777        {
 11778            return true;
 1779        }
 1780
 11781        var currentSlots = _raid.Inventory.CarriedItems.Sum(x => x.Slots);
 11782        return currentSlots + item.Slots <= _raid.BackpackCapacity;
 1783    }
 1784
 1785    private void EnsureMainCharacterHasWeaponFallback()
 1786    {
 281787        var stashHasWeapon = _mainGame.Stash.Any(item => item.Type == ItemType.Weapon);
 321788        var onPersonHasWeapon = _onPersonItems.Any(entry => entry.Item.Type == ItemType.Weapon);
 201789        if (!stashHasWeapon && !onPersonHasWeapon)
 1790        {
 41791            _mainGame.Stash.Add(ItemCatalog.Create("Rusty Knife"));
 1792        }
 201793    }
 1794
 1795    private void NormalizeEquippedSlots()
 1796    {
 191797        NormalizeEquippedSlot(ItemType.Weapon);
 191798        NormalizeEquippedSlot(ItemType.Armor);
 191799        NormalizeEquippedSlot(ItemType.Backpack);
 191800    }
 1801
 1802    private void NormalizeEquippedSlot(ItemType slotType)
 1803    {
 571804        var found = false;
 1921805        for (var i = 0; i < _onPersonItems.Count; i++)
 1806        {
 391807            var entry = _onPersonItems[i];
 391808            if (entry.Item.Type != slotType || !entry.IsEquipped)
 1809            {
 1810                continue;
 1811            }
 1812
 91813            if (!found)
 1814            {
 91815                found = true;
 91816                continue;
 1817            }
 1818
 01819            _onPersonItems[i] = entry with { IsEquipped = false };
 1820        }
 571821    }
 1822
 1823    private void ApplySnapshot(PlayerSnapshot snapshot)
 1824    {
 141825        _mainGame = new GameState([.. snapshot.MainStash]);
 141826        _randomCharacter = snapshot.RandomCharacter is null
 141827            ? null
 141828            : new RandomCharacterState(snapshot.RandomCharacter.Name, [.. snapshot.RandomCharacter.Inventory], snapshot.
 141829        _randomCharacterAvailableAt = snapshot.RandomCharacterAvailableAt;
 1830
 141831        if (_randomCharacter is not null && _randomCharacter.Inventory.Count == 0)
 1832        {
 21833            _randomCharacter = null;
 1834        }
 1835
 141836        _money = snapshot.Money;
 141837        _shopStock = snapshot.ShopStock
 201838            .Select(offer => ItemCatalog.TryGetByItemDefId(offer.ItemDefId, out var item) && item is not null
 201839                ? new ShopStock(offer, item)
 201840                : null)
 201841            .Where(stock => stock is not null)
 141842            .Cast<ShopStock>()
 141843            .ToList();
 171844        _itemRulesById = snapshot.ItemRules.ToDictionary(rule => rule.ItemDefId);
 141845        _acceptedStats = snapshot.AcceptedStats;
 141846        _draftStats = snapshot.DraftStats;
 141847        _availableStatPoints = snapshot.AvailableStatPoints;
 141848        _statsAccepted = snapshot.StatsAccepted;
 141849        _playerConstitution = snapshot.AcceptedStats.Constitution;
 141850        _maxHealth = snapshot.PlayerMaxHealth;
 141851        _onPersonItems = snapshot.OnPersonItems
 51852            .Select(entry => new OnPersonEntry(entry.Item, entry.IsEquipped))
 141853            .ToList();
 141854        _raidEncumbrance = null;
 141855        _raidMaxEncumbrance = null;
 1856
 141857        if (snapshot.ActiveRaid is null)
 1858        {
 121859            _raid = null;
 121860            _inRaid = false;
 121861            _extractHoldActive = false;
 121862            _holdAtExtractUntil = null;
 121863            _extractHoldResolutionInFlight = false;
 121864            _contactState = string.Empty;
 121865            _surpriseSide = string.Empty;
 121866            _initiativeWinner = string.Empty;
 121867            _openingActionsRemaining = 0;
 121868            _surprisePersistenceEligible = false;
 121869            return;
 1870        }
 1871
 21872        ApplyActiveRaidSnapshot(snapshot.ActiveRaid);
 21873    }
 1874
 1875    private void ApplyActiveRaidSnapshot(RaidSnapshot snapshot)
 1876    {
 41877        var broughtItems = (snapshot.EquippedItems ?? []).ToList();
 41878        var carriedItems = (snapshot.CarriedLoot ?? []).ToList();
 41879        _raid = new RaidState(
 41880            snapshot.Health,
 41881            RaidInventory.FromItems(broughtItems, carriedItems, snapshot.BackpackCapacity));
 41882        _raid.Inventory.DiscoveredLoot.Clear();
 41883        _raid.Inventory.DiscoveredLoot.AddRange(snapshot.DiscoveredLoot ?? []);
 41884        _raid.Inventory.MedkitCount = snapshot.Medkits;
 41885        _raid.Inventory.BackpackCapacity = snapshot.BackpackCapacity;
 41886        _raidEncumbrance = snapshot.Encumbrance > 0 || snapshot.MaxEncumbrance > 0 ? snapshot.Encumbrance : null;
 41887        _raidMaxEncumbrance = snapshot.MaxEncumbrance > 0 ? snapshot.MaxEncumbrance : null;
 41888        _extractHoldActive = snapshot.ExtractHoldActive;
 41889        _holdAtExtractUntil = snapshot.HoldAtExtractUntil;
 41890        if (_raidMaxEncumbrance is not null)
 1891        {
 01892            _raid.MaxEncumbrance = _raidMaxEncumbrance.Value;
 1893        }
 41894        _inRaid = true;
 41895        _awaitingDecision = snapshot.AwaitingDecision;
 41896        _challenge = snapshot.Challenge;
 41897        _distanceFromExtract = snapshot.DistanceFromExtract;
 41898        _ammo = snapshot.Ammo;
 41899        _encounterDescription = RaidPresentationCatalog.GetEncounterDescription(
 41900            snapshot.EncounterDescriptionKey,
 41901            Enum.TryParse<EncounterType>(snapshot.EncounterType, ignoreCase: true, out var snapshotEncounterType)
 41902                ? snapshotEncounterType
 41903                : EncounterType.Neutral,
 41904            snapshot.EncounterDescription,
 41905            snapshot.ExtractHoldActive);
 41906        _contactState = string.IsNullOrWhiteSpace(snapshot.ContactState) ? string.Empty : snapshot.ContactState;
 41907        _surpriseSide = string.IsNullOrWhiteSpace(snapshot.SurpriseSide) ? string.Empty : snapshot.SurpriseSide;
 41908        _initiativeWinner = string.IsNullOrWhiteSpace(snapshot.InitiativeWinner) ? string.Empty : snapshot.InitiativeWin
 41909        _openingActionsRemaining = snapshot.OpeningActionsRemaining;
 41910        _surprisePersistenceEligible = snapshot.SurprisePersistenceEligible;
 41911        _enemyName = RaidPresentationCatalog.GetEnemyLabel(snapshot.EnemyKey, snapshot.EnemyName);
 41912        _enemyHealth = snapshot.EnemyHealth;
 41913        _enemyDexterity = snapshot.EnemyDexterity;
 41914        _enemyConstitution = snapshot.EnemyConstitution;
 41915        _enemyStrength = snapshot.EnemyStrength;
 41916        _lootContainer = snapshot.LootContainer;
 41917        _log.Clear();
 41918        _log.AddRange((snapshot.LogEntries ?? []).Select(RaidPresentationCatalog.LocalizeLogEntry));
 1919
 41920        if (!Enum.TryParse<EncounterType>(snapshot.EncounterType, ignoreCase: true, out var encounterType))
 1921        {
 01922            encounterType = EncounterType.Neutral;
 1923        }
 1924
 41925        _encounterType = encounterType;
 41926    }
 1927
 1928    public void Dispose()
 1929    {
 01930        _clockTimer?.Dispose();
 01931    }
 1932
 1933}

Methods/Properties

BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder)
.cctor()
.ctor()
OnInitializedAsync()
<OnInitializedAsync()
get_IsRandomCharacterReady()
get_HasUnprocessedLuckRunLoot()
get_HasUnequippedOnPersonItems()
get_HasEquippedWeapon()
get_HasOverweightOnPersonItems()
get_CanStartMainRaid()
get_CanStartLuckRunRaid()
get_RaidBlockReason()
get_LuckRunBlockReason()
get_CanStashOnPersonItem()
GetOnPersonEncumbrance()
GetMainCharacterMaxEncumbrance()
CanAddOnPersonItem(RaidLoop.Core.Item)
GetPreRaidEncumbranceText()
GetRaidBlockReason()
get_EquippedWeaponUsesAmmo()
get_CurrentMagazineCapacity()
get_EquippedWeaponSupportsSingleShot()
get_EquippedWeaponSupportsBurstFire()
get_EquippedWeaponSupportsFullAuto()
get_CanAttack()
get_CanAttackEnabled()
get_CanBurstFire()
get_CanBurstFireEnabled()
get_CanFullAuto()
get_CanFullAutoEnabled()
get_CanReload()
get_CanUseMedkit()
get_CurrentMedkits()
get_CurrentDiscoveredLoot()
get_CurrentCarriedLoot()
get_VisibleShopStock()
get_CanReallocateStats()
IsSlotType(RaidLoop.Core.ItemType)
FindEquippedIndexForSlot(RaidLoop.Core.ItemType)
ShouldEquipFromStash(RaidLoop.Core.Item)
GetStashPrimaryActionLabel(RaidLoop.Core.Item)
SellStashItemAsync()
MoveStashToOnPersonAsync()
SellOnPersonItemAsync()
StashOnPersonItemAsync()
EquipOnPersonItemAsync()
UnequipOnPersonItemAsync()
BuyFromShopAsync()
AcceptStatsAsync()
ReallocateStatsAsync()
IncrementDraftStat(System.String)
DecrementDraftStat(System.String)
GetDraftStatValue(System.String)
GetDraftModifier(System.String)
CanIncreaseDraftStat(System.String)
CanDecreaseDraftStat(System.String)
SetDraftStatValue(System.String,System.Int32)
StartMainRaidAsync()
StartRandomRaidAsync()
StoreLuckRunItemAsync()
MoveLuckRunItemToForRaidAsync()
SellLuckRunItemAsync()
SignOutAsync()
ExecuteProfileActionAsync()
ExecuteRaidActionAsync()
ApplyActionResult(RaidLoop.Core.Contracts.GameActionResult)
ApplyProjectedState(System.Text.Json.JsonElement)
ApplyPlayerProjection(System.Text.Json.JsonElement)
ApplyRaidProjection(System.Text.Json.JsonElement)
CreateRaidActionPayload(System.Object)
AddKnownLogCount(System.Collections.Generic.Dictionary`2<System.String,System.Object>)
ClearRaidState()
ReadItemList(System.Text.Json.JsonElement)
ReadItemListFromProperty(System.Text.Json.JsonElement,System.String)
ReadOnPersonEntries(System.Text.Json.JsonElement)
TryReadOnPersonEntries(System.Text.Json.JsonElement,System.Collections.Generic.List`1<RaidLoop.Client.Services.OnPersonEntry>&)
TryReadRandomCharacter(System.Text.Json.JsonElement,RaidLoop.Client.Services.RandomCharacterState&)
TryReadItemList(System.Text.Json.JsonElement,System.Collections.Generic.List`1<RaidLoop.Core.Item>&)
TryReadItem(System.Text.Json.JsonElement,RaidLoop.Core.Item&)
ApplyItemRulesProjection(System.Text.Json.JsonElement)
TryReadPlayerStats(System.Text.Json.JsonElement,RaidLoop.Core.PlayerStats&)
ReadStringListFromProperty(System.Text.Json.JsonElement,System.String)
TryGetProjection(System.Text.Json.JsonElement,System.String,System.Text.Json.JsonElement&)
TryGetInt32(System.Text.Json.JsonElement,System.String,System.Int32&)
TryGetBool(System.Text.Json.JsonElement,System.String,System.Boolean&)
TryGetString(System.Text.Json.JsonElement,System.String,System.String&)
TryGetNullableDateTimeOffset(System.Text.Json.JsonElement,System.String,System.Nullable`1<System.DateTimeOffset>&)
ExecuteLootActionAsync()
GetRandomCooldownText()
GetEquippedWeaponName()
GetAmmoHudText()
GetEncounterTitle()
AttackAsync()
BurstFireAsync()
FullAutoAsync()
UseMedkitAsync()
ReloadAsync()
FleeAsync()
TakeLootAsync(RaidLoop.Core.Item)
DropCarriedAsync(RaidLoop.Core.Item)
DropEquippedAsync(RaidLoop.Core.ItemType)
EquipFromDiscoveredAsync(RaidLoop.Core.Item)
EquipFromCarriedAsync(RaidLoop.Core.Item)
GetRaidEncumbrance()
GetRaidMaxEncumbrance()
GetRaidEncumbranceText()
IsEquipableRaidItem(RaidLoop.Core.Item)
CanEquipRaidItem(RaidLoop.Core.Item)
GetEquippedItems()
AttemptExtractAsync()
GoDeeper()
MoveTowardExtract()
StartExtractHoldAsync()
CancelExtractHoldAsync()
ResolveExpiredExtractHoldAsync()
IsExtractHoldEffectivelyActive()
HasExpiredExtractHold()
ReportHandledErrorAsync(System.String,System.String,System.Exception,System.Object)
GetLootSlotCount()
GetReallocateStatCost()
GetReallocateStatCostLabel()
GetBuyPrice(RaidLoop.Core.Item)
CanBuyItem(RaidLoop.Core.Item)
GetSellPrice(RaidLoop.Core.Item)
CanSellItem(RaidLoop.Core.Item)
CanLootItem(RaidLoop.Core.Item)
EnsureMainCharacterHasWeaponFallback()
NormalizeEquippedSlots()
NormalizeEquippedSlot(RaidLoop.Core.ItemType)
ApplySnapshot(RaidLoop.Core.Contracts.PlayerSnapshot)
ApplyActiveRaidSnapshot(RaidLoop.Core.Contracts.RaidSnapshot)
Dispose()