< Summary

Line coverage
75%
Covered lines: 770
Uncovered lines: 254
Coverable lines: 1024
Total lines: 2014
Line coverage: 75.1%
Branch coverage
58%
Covered branches: 463
Total branches: 787
Branch coverage: 58.8%
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.39%10610696.62%
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(...)70%1010100%
File 2: TryReadItemList(...)87.5%8888.88%
File 2: TryReadItem(...)61.84%777693.84%
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(...)33.33%12644.44%
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"];
 6615    private int _playerConstitution = 10;
 6616    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
 6623    private GameState _mainGame = new([]);
 6624    private List<ShopStock> _shopStock = [];
 6625    private Dictionary<int, ItemRuleSnapshot> _itemRulesById = [];
 6626    private List<OnPersonEntry> _onPersonItems = [];
 27    private int _money;
 6628    private PlayerStats _acceptedStats = PlayerStats.Default;
 6629    private PlayerStats _draftStats = PlayerStats.Default;
 6630    private int _availableStatPoints = PlayerStatRules.StartingPool;
 31    private bool _statsAccepted;
 32
 33    private RandomCharacterState? _randomCharacter;
 6634    private DateTimeOffset _randomCharacterAvailableAt = DateTimeOffset.MinValue;
 35    private System.Threading.Timer? _clockTimer;
 36
 37    private RaidState? _raid;
 6638    private bool _isLoading = true;
 6639    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;
 6647    private EncounterType _encounterType = EncounterType.Neutral;
 6648    private string _encounterDescription = string.Empty;
 6649    private string _contactState = string.Empty;
 6650    private string _surpriseSide = string.Empty;
 6651    private string _initiativeWinner = string.Empty;
 52    private int _openingActionsRemaining;
 53    private bool _surprisePersistenceEligible;
 54
 6655    private string _enemyName = string.Empty;
 56    private int _enemyHealth;
 57    private int _enemyDexterity;
 58    private int _enemyConstitution;
 59    private int _enemyStrength;
 60
 6661    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;
 6667    private string _resultMessage = string.Empty;
 6668    private string _activeRaidId = string.Empty;
 6669    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    {
 39536        if (result.Projections is { ValueKind: JsonValueKind.Object } projections)
 537        {
 37538            ApplyProjectedState(projections);
 539        }
 540
 39541        if (!string.IsNullOrWhiteSpace(result.Message))
 542        {
 12543            _resultMessage = result.Message;
 544        }
 39545    }
 546
 547    private void ApplyProjectedState(JsonElement projections)
 548    {
 37549        var updatedStash = false;
 37550        var updatedLoadout = false;
 551
 37552        if (TryGetProjection(projections, "economy", out var economy)
 37553            && TryGetInt32(economy, "money", out var money))
 554        {
 11555            _money = money;
 556        }
 557
 37558        if (TryGetProjection(projections, "stash", out var stash)
 37559            && 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
 37568        if (TryGetProjection(projections, "loadout", out var loadout)
 37569            && TryGetProjection(loadout, "onPersonItems", out var onPersonItems))
 570        {
 13571            if (TryReadOnPersonEntries(onPersonItems, out var parsedOnPersonItems))
 572            {
 12573                _onPersonItems = parsedOnPersonItems;
 12574                updatedLoadout = true;
 575            }
 576        }
 577
 37578        if (TryGetProjection(projections, "player", out var playerProjection))
 579        {
 8580            ApplyPlayerProjection(playerProjection);
 581        }
 582
 37583        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
 37609        if (TryGetProjection(projections, "raid", out var raid))
 610        {
 26611            if (raid.ValueKind == JsonValueKind.Null)
 612            {
 1613                ClearRaidState();
 614            }
 615            else
 616            {
 25617                ApplyRaidProjection(raid);
 618            }
 619        }
 620
 37621        if (updatedStash || updatedLoadout)
 622        {
 12623            EnsureMainCharacterHasWeaponFallback();
 624        }
 625
 37626        if (updatedLoadout)
 627        {
 12628            NormalizeEquippedSlots();
 629        }
 37630    }
 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    {
 25670        var freshRaid = _raid is null;
 25671        var raidEncumbranceProjected = false;
 25672        var raidMaxEncumbranceProjected = false;
 25673        var inventoryChanged = false;
 25674        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
 25703        var raidState = _raid!;
 25704        var inventory = raidState.Inventory;
 25705        var health = raidState.Health;
 25706        var backpackCapacity = raidState.BackpackCapacity;
 25707        var hasRaidPatch = freshRaid;
 25708        var holdProjectionSeen = false;
 25709        var parsedExtractHoldActive = false;
 25710        DateTimeOffset? parsedHoldAtExtractUntil = null;
 25711        var extractHoldActiveSeen = false;
 25712        var holdAtExtractUntilSeen = false;
 713
 25714        if (TryGetInt32(raid, "health", out var parsedHealth))
 715        {
 19716            health = parsedHealth;
 19717            hasRaidPatch = true;
 718        }
 719
 25720        if (TryGetInt32(raid, "backpackCapacity", out var parsedBackpackCapacity))
 721        {
 6722            backpackCapacity = parsedBackpackCapacity;
 6723            hasRaidPatch = true;
 724        }
 725
 25726        if (TryGetInt32(raid, "encumbrance", out var parsedEncumbrance))
 727        {
 6728            _raidEncumbrance = parsedEncumbrance;
 6729            raidEncumbranceProjected = true;
 6730            hasRaidPatch = true;
 731        }
 732
 25733        if (TryGetInt32(raid, "maxEncumbrance", out var parsedMaxEncumbrance))
 734        {
 6735            _raidMaxEncumbrance = parsedMaxEncumbrance;
 6736            raidState.MaxEncumbrance = parsedMaxEncumbrance;
 6737            raidMaxEncumbranceProjected = true;
 6738            hasRaidPatch = true;
 739        }
 740
 25741        if (TryGetBool(raid, "extractHoldActive", out var extractHoldActive))
 742        {
 5743            parsedExtractHoldActive = extractHoldActive;
 5744            extractHoldActiveSeen = true;
 5745            holdProjectionSeen = true;
 5746            hasRaidPatch = true;
 747        }
 748
 25749        if (TryGetNullableDateTimeOffset(raid, "holdAtExtractUntil", out var holdAtExtractUntil))
 750        {
 4751            parsedHoldAtExtractUntil = holdAtExtractUntil;
 4752            holdAtExtractUntilSeen = true;
 4753            holdProjectionSeen = true;
 4754            hasRaidPatch = true;
 755        }
 756
 25757        if (holdProjectionSeen)
 758        {
 5759            _extractHoldActive = extractHoldActiveSeen ? parsedExtractHoldActive : false;
 5760            _holdAtExtractUntil = holdAtExtractUntilSeen ? parsedHoldAtExtractUntil : null;
 761        }
 762
 25763        if (TryGetProjection(raid, "equippedItems", out var equippedItems))
 764        {
 18765            if (TryReadItemList(equippedItems, out var items))
 766            {
 32767                inventory.EquippedWeapon = items.FirstOrDefault(item => item.Type == ItemType.Weapon);
 44768                inventory.EquippedArmor = items.FirstOrDefault(item => item.Type == ItemType.Armor);
 44769                inventory.EquippedBackpack = items.FirstOrDefault(item => item.Type == ItemType.Backpack);
 17770                inventoryChanged = true;
 17771                hasRaidPatch = true;
 772            }
 773        }
 774
 25775        if (TryGetProjection(raid, "carriedLoot", out var carriedLoot))
 776        {
 18777            if (TryReadItemList(carriedLoot, out var items))
 778            {
 17779                inventory.CarriedItems.Clear();
 17780                inventory.CarriedItems.AddRange(items);
 17781                inventoryChanged = true;
 17782                hasRaidPatch = true;
 783            }
 784        }
 785
 25786        if (TryGetProjection(raid, "discoveredLoot", out var discoveredLoot))
 787        {
 17788            if (TryReadItemList(discoveredLoot, out var items))
 789            {
 16790                inventory.DiscoveredLoot.Clear();
 16791                inventory.DiscoveredLoot.AddRange(items);
 16792                inventoryChanged = true;
 16793                hasRaidPatch = true;
 794            }
 795        }
 796
 25797        if (TryGetInt32(raid, "medkits", out var medkits))
 798        {
 6799            inventory.MedkitCount = medkits;
 6800            inventoryChanged = true;
 6801            hasRaidPatch = true;
 802        }
 803
 25804        if (TryGetInt32(raid, "ammo", out var ammo))
 805        {
 18806            _ammo = ammo;
 18807            hasRaidPatch = true;
 808        }
 809
 25810        if (TryGetInt32(raid, "challenge", out var challenge))
 811        {
 13812            _challenge = challenge;
 13813            hasRaidPatch = true;
 814        }
 815
 25816        if (TryGetInt32(raid, "distanceFromExtract", out var distanceFromExtract))
 817        {
 13818            _distanceFromExtract = distanceFromExtract;
 13819            hasRaidPatch = true;
 820        }
 821
 25822        if (TryGetBool(raid, "awaitingDecision", out var awaitingDecision))
 823        {
 16824            _awaitingDecision = awaitingDecision;
 16825            hasRaidPatch = true;
 826        }
 827
 25828        string? encounterDescription = null;
 25829        string? encounterDescriptionKey = null;
 25830        if (TryGetString(raid, "encounterDescription", out var encounterDescriptionText))
 831        {
 16832            encounterDescription = encounterDescriptionText;
 16833            hasRaidPatch = true;
 834        }
 25835        if (TryGetString(raid, "encounterDescriptionKey", out var encounterDescriptionKeyText))
 836        {
 0837            encounterDescriptionKey = encounterDescriptionKeyText;
 0838            hasRaidPatch = true;
 839        }
 840
 25841        if (TryGetString(raid, "contactState", out var contactState))
 842        {
 15843            _contactState = string.IsNullOrWhiteSpace(contactState) ? string.Empty : contactState;
 15844            hasRaidPatch = true;
 845        }
 846
 25847        if (TryGetString(raid, "surpriseSide", out var surpriseSide))
 848        {
 15849            _surpriseSide = string.IsNullOrWhiteSpace(surpriseSide) ? string.Empty : surpriseSide;
 15850            hasRaidPatch = true;
 851        }
 852
 25853        if (TryGetString(raid, "initiativeWinner", out var initiativeWinner))
 854        {
 15855            _initiativeWinner = string.IsNullOrWhiteSpace(initiativeWinner) ? string.Empty : initiativeWinner;
 15856            hasRaidPatch = true;
 857        }
 858
 25859        if (TryGetInt32(raid, "openingActionsRemaining", out var openingActionsRemaining))
 860        {
 15861            _openingActionsRemaining = openingActionsRemaining;
 15862            hasRaidPatch = true;
 863        }
 864
 25865        if (TryGetBool(raid, "surprisePersistenceEligible", out var surprisePersistenceEligible))
 866        {
 15867            _surprisePersistenceEligible = surprisePersistenceEligible;
 15868            hasRaidPatch = true;
 869        }
 870
 25871        string? enemyName = null;
 25872        string? enemyKey = null;
 25873        if (TryGetString(raid, "enemyName", out var enemyNameText))
 874        {
 16875            enemyName = enemyNameText;
 16876            hasRaidPatch = true;
 877        }
 25878        if (TryGetString(raid, "enemyKey", out var enemyKeyText))
 879        {
 0880            enemyKey = enemyKeyText;
 0881            hasRaidPatch = true;
 882        }
 883
 25884        if (TryGetInt32(raid, "enemyHealth", out var enemyHealth))
 885        {
 19886            _enemyHealth = enemyHealth;
 19887            hasRaidPatch = true;
 888        }
 889
 25890        if (TryGetInt32(raid, "enemyDexterity", out var enemyDexterity))
 891        {
 0892            _enemyDexterity = enemyDexterity;
 0893            hasRaidPatch = true;
 894        }
 895
 25896        if (TryGetInt32(raid, "enemyConstitution", out var enemyConstitution))
 897        {
 1898            _enemyConstitution = enemyConstitution;
 1899            hasRaidPatch = true;
 900        }
 901
 25902        if (TryGetInt32(raid, "enemyStrength", out var enemyStrength))
 903        {
 1904            _enemyStrength = enemyStrength;
 1905            hasRaidPatch = true;
 906        }
 907
 25908        if (TryGetString(raid, "lootContainer", out var lootContainer))
 909        {
 16910            _lootContainer = lootContainer;
 16911            hasRaidPatch = true;
 912        }
 913
 25914        if (TryGetString(raid, "encounterType", out var encounterTypeText)
 25915            && Enum.TryParse<EncounterType>(encounterTypeText, ignoreCase: true, out var encounterType))
 916        {
 16917            _encounterType = encounterType;
 16918            hasRaidPatch = true;
 919        }
 920
 25921        if (encounterDescription is not null)
 922        {
 16923            _encounterDescription = RaidPresentationCatalog.GetEncounterDescription(encounterDescriptionKey, _encounterT
 924        }
 925
 25926        if (enemyName is not null || enemyKey is not null)
 927        {
 16928            _enemyName = RaidPresentationCatalog.GetEnemyLabel(enemyKey, enemyName);
 929        }
 930
 25931        if (TryGetProjection(raid, "logEntriesAdded", out var logEntriesAdded))
 932        {
 1933            if (logEntriesAdded.ValueKind == JsonValueKind.Array)
 934            {
 1935                _log.AddRange(ReadStringListFromProperty(raid, "logEntriesAdded").Select(RaidPresentationCatalog.Localiz
 1936                hasRaidPatch = true;
 937            }
 938        }
 24939        else if (TryGetProjection(raid, "logEntries", out var logEntries))
 940        {
 18941            if (logEntries.ValueKind == JsonValueKind.Array)
 942            {
 18943                _log.Clear();
 18944                _log.AddRange(ReadStringListFromProperty(raid, "logEntries").Select(RaidPresentationCatalog.LocalizeLogE
 18945                hasRaidPatch = true;
 946            }
 947        }
 948
 25949        inventory.BackpackCapacity = backpackCapacity;
 25950        raidState.Health = health;
 25951        raidState.BackpackCapacity = backpackCapacity;
 952
 25953        if (inventoryChanged && !raidEncumbranceProjected)
 954        {
 13955            _raidEncumbrance = null;
 956        }
 957
 25958        if (inventoryChanged && !raidMaxEncumbranceProjected)
 959        {
 13960            _raidMaxEncumbrance = null;
 961        }
 962
 25963        if (!freshRaid && hasRaidPatch)
 964        {
 18965            _raid = raidState;
 966        }
 967
 25968        if (freshRaid || hasRaidPatch)
 969        {
 25970            _inRaid = true;
 971        }
 25972    }
 973
 974    private object CreateRaidActionPayload(object payload)
 975    {
 17976        return payload switch
 17977        {
 0978            null => new { knownLogCount = _log.Count },
 17979            _ => JsonSerializer.Deserialize<Dictionary<string, object?>>(
 17980                JsonSerializer.Serialize(payload)) is { } values
 17981                ? AddKnownLogCount(values)
 17982                : new Dictionary<string, object?> { ["knownLogCount"] = _log.Count }
 17983        };
 984    }
 985
 986    private Dictionary<string, object?> AddKnownLogCount(Dictionary<string, object?> values)
 987    {
 17988        values["knownLogCount"] = _log.Count;
 17989        return values;
 990    }
 991
 992    private void ClearRaidState()
 993    {
 1994        _raid = null;
 1995        _raidEncumbrance = null;
 1996        _raidMaxEncumbrance = null;
 1997        _extractHoldActive = false;
 1998        _holdAtExtractUntil = null;
 1999        _extractHoldResolutionInFlight = false;
 11000        _inRaid = false;
 11001        _awaitingDecision = false;
 11002        _challenge = 0;
 11003        _distanceFromExtract = 0;
 11004        _ammo = 0;
 11005        _encounterType = EncounterType.Neutral;
 11006        _encounterDescription = string.Empty;
 11007        _contactState = string.Empty;
 11008        _surpriseSide = string.Empty;
 11009        _initiativeWinner = string.Empty;
 11010        _openingActionsRemaining = 0;
 11011        _surprisePersistenceEligible = false;
 11012        _enemyName = string.Empty;
 11013        _enemyHealth = 0;
 11014        _enemyDexterity = 0;
 11015        _enemyConstitution = 0;
 11016        _enemyStrength = 0;
 11017        _lootContainer = string.Empty;
 11018        _log.Clear();
 11019    }
 1020
 1021    private static List<Item> ReadItemList(JsonElement items)
 1022    {
 01023        return TryReadItemList(items, out var parsedItems) ? parsedItems : [];
 1024    }
 1025
 1026    private static List<Item> ReadItemListFromProperty(JsonElement parent, string propertyName)
 1027    {
 01028        return TryGetProjection(parent, propertyName, out var items) ? ReadItemList(items) : [];
 1029    }
 1030
 1031    private static List<OnPersonEntry> ReadOnPersonEntries(JsonElement onPersonItems)
 1032    {
 01033        return TryReadOnPersonEntries(onPersonItems, out var parsedEntries) ? parsedEntries : [];
 1034    }
 1035
 1036    private static bool TryReadOnPersonEntries(JsonElement onPersonItems, out List<OnPersonEntry> entries)
 1037    {
 131038        entries = [];
 131039        if (onPersonItems.ValueKind != JsonValueKind.Array)
 1040        {
 01041            return false;
 1042        }
 1043
 131044        var hasValidEntry = false;
 461045        foreach (var entry in onPersonItems.EnumerateArray())
 1046        {
 101047            if (!TryGetProjection(entry, "item", out var itemElement)
 101048                && !TryGetProjection(entry, "Item", out itemElement))
 1049            {
 1050                continue;
 1051            }
 1052
 91053            if (!TryReadItem(itemElement, out var parsedItem))
 1054            {
 1055                continue;
 1056            }
 1057
 91058            entries.Add(new OnPersonEntry(
 91059                parsedItem,
 91060                TryGetBool(entry, "isEquipped", out var isEquipped) && isEquipped));
 91061            hasValidEntry = true;
 1062        }
 1063
 131064        return hasValidEntry || onPersonItems.GetArrayLength() == 0;
 1065    }
 1066
 1067    private static bool TryReadRandomCharacter(JsonElement randomCharacter, out RandomCharacterState parsedRandomCharact
 1068    {
 61069        parsedRandomCharacter = null!;
 61070        var name = TryGetString(randomCharacter, "name", out var randomCharacterName)
 61071            ? randomCharacterName
 61072            : string.Empty;
 61073        var inventory = TryGetProjection(randomCharacter, "inventory", out var inventoryItems)
 61074            && TryReadItemList(inventoryItems, out var parsedInventory)
 61075            ? parsedInventory
 61076            : [];
 61077        if (!TryGetProjection(randomCharacter, "stats", out var statsElement) || !TryReadPlayerStats(statsElement, out v
 1078        {
 11079            return false;
 1080        }
 1081
 51082        parsedRandomCharacter = new RandomCharacterState(name, inventory, parsedStats);
 51083        return true;
 1084    }
 1085
 1086    private static bool TryReadItemList(JsonElement items, out List<Item> parsedItems)
 1087    {
 711088        parsedItems = [];
 711089        if (items.ValueKind != JsonValueKind.Array)
 1090        {
 01091            return false;
 1092        }
 1093
 711094        var hasValidItem = false;
 2301095        foreach (var entry in items.EnumerateArray())
 1096        {
 441097            if (!TryReadItem(entry, out var parsedItem))
 1098            {
 1099                continue;
 1100            }
 1101
 401102            parsedItems.Add(parsedItem);
 401103            hasValidItem = true;
 1104        }
 1105
 711106        return hasValidItem || items.GetArrayLength() == 0;
 1107    }
 1108
 1109    private static bool TryReadItem(JsonElement item, out Item parsedItem)
 1110    {
 571111        var hasItemDefId = TryGetInt32(item, "itemDefId", out var itemDefId) && itemDefId > 0;
 571112        var itemKey = TryGetString(item, "itemKey", out var itemKeyValue)
 571113            ? itemKeyValue
 571114            : TryGetString(item, "ItemKey", out var itemKeyUpperCase)
 571115                ? itemKeyUpperCase
 571116                : string.Empty;
 571117        var name = TryGetString(item, "name", out var itemName)
 571118            ? itemName
 571119            : TryGetString(item, "Name", out var itemNameUpperCase)
 571120                ? itemNameUpperCase
 571121                : string.Empty;
 1122
 571123        if (hasItemDefId
 571124            && ItemCatalog.TryGetByItemDefId(itemDefId, out var catalogItemById)
 571125            && catalogItemById is not null)
 1126        {
 371127            parsedItem = catalogItemById;
 371128            return true;
 1129        }
 1130
 201131        if (!hasItemDefId
 201132            && !string.IsNullOrWhiteSpace(itemKey)
 201133            && ItemCatalog.TryGetByKey(itemKey, out var catalogItemByKey)
 201134            && catalogItemByKey is not null)
 1135        {
 01136            parsedItem = catalogItemByKey;
 01137            return true;
 1138        }
 1139
 201140        if (!hasItemDefId
 201141            && !string.IsNullOrWhiteSpace(name)
 201142            && ItemCatalog.TryGet(name, out var catalogItem))
 1143        {
 141144            parsedItem = catalogItem!;
 141145            return true;
 1146        }
 1147
 61148        if (!hasItemDefId && string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(itemKey))
 1149        {
 41150            parsedItem = default!;
 41151            return false;
 1152        }
 1153
 21154        var type = TryGetInt32(item, "type", out var parsedType) && Enum.IsDefined(typeof(ItemType), parsedType)
 21155            ? (ItemType)parsedType
 21156            : TryGetInt32(item, "Type", out var parsedTypeUpperCase) && Enum.IsDefined(typeof(ItemType), parsedTypeUpper
 21157                ? (ItemType)parsedTypeUpperCase
 21158            : ItemType.Sellable;
 21159        var value = TryGetInt32(item, "value", out var parsedValue)
 21160            ? parsedValue
 21161            : TryGetInt32(item, "Value", out var parsedValueUpperCase)
 21162                ? parsedValueUpperCase
 21163                : 1;
 21164        if (!TryGetInt32(item, "weight", out var parsedWeight)
 21165            && !TryGetInt32(item, "Weight", out parsedWeight))
 1166        {
 01167            parsedItem = default!;
 01168            return false;
 1169        }
 21170        var slots = TryGetInt32(item, "slots", out var parsedSlots)
 21171            ? parsedSlots
 21172            : TryGetInt32(item, "Slots", out var parsedSlotsUpperCase)
 21173                ? parsedSlotsUpperCase
 21174                : 1;
 21175        var rarity = TryGetInt32(item, "rarity", out var parsedRarity) && Enum.IsDefined(typeof(Rarity), parsedRarity)
 21176            ? (Rarity)parsedRarity
 21177            : TryGetInt32(item, "Rarity", out var parsedRarityUpperCase) && Enum.IsDefined(typeof(Rarity), parsedRarityU
 21178                ? (Rarity)parsedRarityUpperCase
 21179            : Rarity.Common;
 21180        var displayRarity = TryGetInt32(item, "displayRarity", out var parsedDisplayRarity) && Enum.IsDefined(typeof(Dis
 21181            ? (DisplayRarity)parsedDisplayRarity
 21182            : TryGetInt32(item, "DisplayRarity", out var parsedDisplayRarityUpperCase) && Enum.IsDefined(typeof(DisplayR
 21183                ? (DisplayRarity)parsedDisplayRarityUpperCase
 21184            : DisplayRarity.Common;
 1185
 21186        parsedItem = new Item(string.IsNullOrWhiteSpace(name) ? itemKey : name, type, parsedWeight, value, slots, rarity
 21187        {
 21188            ItemDefId = hasItemDefId ? itemDefId : 0,
 21189            Key = itemKey
 21190        };
 21191        return true;
 1192    }
 1193
 1194    private void ApplyItemRulesProjection(JsonElement itemRules)
 1195    {
 01196        if (itemRules.ValueKind != JsonValueKind.Array)
 1197        {
 01198            return;
 1199        }
 1200
 01201        var updatedRules = new Dictionary<int, ItemRuleSnapshot>();
 01202        foreach (var rule in itemRules.EnumerateArray())
 1203        {
 01204            if (!TryGetInt32(rule, "itemDefId", out var itemDefId) || itemDefId <= 0)
 1205            {
 1206                continue;
 1207            }
 1208
 01209            var type = TryGetInt32(rule, "type", out var parsedType) && Enum.IsDefined(typeof(ItemType), parsedType)
 01210                ? (ItemType)parsedType
 01211                : ItemType.Sellable;
 01212            var weight = TryGetInt32(rule, "weight", out var parsedWeight) ? parsedWeight : 0;
 01213            var slots = TryGetInt32(rule, "slots", out var parsedSlots) ? parsedSlots : 1;
 01214            var rarity = TryGetInt32(rule, "rarity", out var parsedRarity) && Enum.IsDefined(typeof(Rarity), parsedRarit
 01215                ? (Rarity)parsedRarity
 01216                : Rarity.Common;
 1217
 01218            updatedRules[itemDefId] = new ItemRuleSnapshot(itemDefId, type, weight, slots, rarity);
 1219        }
 1220
 01221        if (updatedRules.Count > 0)
 1222        {
 01223            _itemRulesById = updatedRules;
 1224        }
 01225    }
 1226
 1227    private static bool TryReadPlayerStats(JsonElement statsElement, out PlayerStats parsedStats)
 1228    {
 201229        var strength = TryGetInt32(statsElement, "strength", out var parsedStrength)
 201230            ? parsedStrength
 201231            : TryGetInt32(statsElement, "Strength", out var parsedStrengthUpper)
 201232                ? parsedStrengthUpper
 201233                : PlayerStatRules.MinimumScore;
 201234        var dexterity = TryGetInt32(statsElement, "dexterity", out var parsedDexterity)
 201235            ? parsedDexterity
 201236            : TryGetInt32(statsElement, "Dexterity", out var parsedDexterityUpper)
 201237                ? parsedDexterityUpper
 201238                : PlayerStatRules.MinimumScore;
 201239        var constitution = TryGetInt32(statsElement, "constitution", out var parsedConstitution)
 201240            ? parsedConstitution
 201241            : TryGetInt32(statsElement, "Constitution", out var parsedConstitutionUpper)
 201242                ? parsedConstitutionUpper
 201243                : PlayerStatRules.MinimumScore;
 201244        var intelligence = TryGetInt32(statsElement, "intelligence", out var parsedIntelligence)
 201245            ? parsedIntelligence
 201246            : TryGetInt32(statsElement, "Intelligence", out var parsedIntelligenceUpper)
 201247                ? parsedIntelligenceUpper
 201248                : PlayerStatRules.MinimumScore;
 201249        var wisdom = TryGetInt32(statsElement, "wisdom", out var parsedWisdom)
 201250            ? parsedWisdom
 201251            : TryGetInt32(statsElement, "Wisdom", out var parsedWisdomUpper)
 201252                ? parsedWisdomUpper
 201253                : PlayerStatRules.MinimumScore;
 201254        var charisma = TryGetInt32(statsElement, "charisma", out var parsedCharisma)
 201255            ? parsedCharisma
 201256            : TryGetInt32(statsElement, "Charisma", out var parsedCharismaUpper)
 201257                ? parsedCharismaUpper
 201258                : PlayerStatRules.MinimumScore;
 1259
 201260        parsedStats = new PlayerStats(strength, dexterity, constitution, intelligence, wisdom, charisma);
 201261        return true;
 1262    }
 1263
 1264    private static List<string> ReadStringListFromProperty(JsonElement parent, string propertyName)
 1265    {
 191266        if (!TryGetProjection(parent, propertyName, out var items) || items.ValueKind != JsonValueKind.Array)
 1267        {
 01268            return [];
 1269        }
 1270
 191271        return items.EnumerateArray()
 211272            .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : string.Empty)
 211273            .Where(item => !string.IsNullOrWhiteSpace(item))
 191274            .ToList();
 1275    }
 1276
 1277    private static bool TryGetProjection(JsonElement parent, string propertyName, out JsonElement value)
 1278    {
 16011279        if (parent.ValueKind == JsonValueKind.Object)
 1280        {
 230511281            foreach (var property in parent.EnumerateObject())
 1282            {
 103721283                if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
 1284                {
 7031285                    value = property.Value;
 7031286                    return true;
 1287                }
 1288            }
 1289        }
 1290
 8981291        value = default;
 8981292        return false;
 7031293    }
 1294
 1295    private static bool TryGetInt32(JsonElement parent, string propertyName, out int value)
 1296    {
 6011297        if (TryGetProjection(parent, propertyName, out var property))
 1298        {
 2761299            if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out value))
 1300            {
 2601301                return true;
 1302            }
 1303
 161304            if (property.ValueKind == JsonValueKind.String && int.TryParse(property.GetString(), out value))
 1305            {
 01306                return true;
 1307            }
 1308        }
 1309
 3411310        value = default;
 3411311        return false;
 1312    }
 1313
 1314    private static bool TryGetBool(JsonElement parent, string propertyName, out bool value)
 1315    {
 921316        if (TryGetProjection(parent, propertyName, out var property))
 1317        {
 531318            if (property.ValueKind is JsonValueKind.True or JsonValueKind.False)
 1319            {
 491320                value = property.GetBoolean();
 491321                return true;
 1322            }
 1323
 41324            if (property.ValueKind == JsonValueKind.String && bool.TryParse(property.GetString(), out value))
 1325            {
 01326                return true;
 1327            }
 1328        }
 1329
 431330        value = default;
 431331        return false;
 1332    }
 1333
 1334    private static bool TryGetString(JsonElement parent, string propertyName, out string value)
 1335    {
 4481336        if (TryGetProjection(parent, propertyName, out var property) && property.ValueKind == JsonValueKind.String)
 1337        {
 1371338            value = property.GetString() ?? string.Empty;
 1371339            return true;
 1340        }
 1341
 3111342        value = string.Empty;
 3111343        return false;
 1344    }
 1345
 1346    private static bool TryGetNullableDateTimeOffset(JsonElement parent, string propertyName, out DateTimeOffset? value)
 1347    {
 251348        if (TryGetProjection(parent, propertyName, out var property))
 1349        {
 41350            if (property.ValueKind == JsonValueKind.Null)
 1351            {
 21352                value = null;
 21353                return true;
 1354            }
 1355
 21356            if (property.ValueKind == JsonValueKind.String
 21357                && DateTimeOffset.TryParse(property.GetString(), out var parsed))
 1358            {
 21359                value = parsed;
 21360                return true;
 1361            }
 1362        }
 1363
 211364        value = default;
 211365        return false;
 1366    }
 1367
 1368    private async Task ExecuteLootActionAsync(string action, Item item, string eventName)
 1369    {
 21370        await ExecuteRaidActionAsync(action, new { itemDefId = item.ItemDefId });
 21371        GameEventLog.Append(new GameEvent(
 21372            eventName,
 21373            _activeRaidId,
 21374            GameEventLog.CreateItemSnapshots([item]),
 21375            DateTimeOffset.UtcNow));
 21376    }
 1377
 1378    private string GetRandomCooldownText()
 1379    {
 11380        var left = _randomCharacterAvailableAt - DateTimeOffset.UtcNow;
 11381        if (left < TimeSpan.Zero)
 1382        {
 01383            left = TimeSpan.Zero;
 1384        }
 1385
 11386        return left.TotalHours >= 1
 11387            ? $"{(int)left.TotalHours}:{left.Minutes:D2}:{left.Seconds:D2}"
 11388            : $"{left.Minutes:D2}:{left.Seconds:D2}";
 1389    }
 1390
 1391    private string GetEquippedWeaponName()
 1392    {
 01393        var weapon = _raid?.Inventory.EquippedWeapon;
 01394        return ItemPresentationCatalog.GetLabel(weapon) is { Length: > 0 } label
 01395            ? label
 01396            : ItemPresentationCatalog.GetLabel(ItemCatalog.GetByItemDefId(FallbackKnifeItemDefId));
 1397    }
 1398
 1399    private string GetAmmoHudText()
 1400    {
 01401        if (!EquippedWeaponUsesAmmo)
 1402        {
 01403            return "Ammo: âˆž";
 1404        }
 1405
 01406        return $"Ammo: {_ammo} / {CurrentMagazineCapacity}";
 1407    }
 1408
 1409    private string GetEncounterTitle()
 1410    {
 01411        return _encounterType switch
 01412        {
 01413            EncounterType.Combat => "Combat Encounter",
 01414            EncounterType.Loot => "Loot Encounter",
 01415            EncounterType.Neutral => "Area Clear",
 01416            EncounterType.Extraction => "Extraction Opportunity",
 01417            _ => "Encounter"
 01418        };
 1419    }
 1420
 1421    private async Task AttackAsync()
 1422    {
 21423        if (_raid is null || _encounterType != EncounterType.Combat)
 1424        {
 01425            return;
 1426        }
 21427        await ExecuteRaidActionAsync("attack", new { target = "enemy" });
 21428    }
 1429
 1430    private async Task BurstFireAsync()
 1431    {
 01432        if (_raid is null || _encounterType != EncounterType.Combat)
 1433        {
 01434            return;
 1435        }
 01436        await ExecuteRaidActionAsync("burst-fire", new { target = "enemy" });
 01437    }
 1438
 1439    private async Task FullAutoAsync()
 1440    {
 01441        if (_raid is null || _encounterType != EncounterType.Combat)
 1442        {
 01443            return;
 1444        }
 1445
 01446        await ExecuteRaidActionAsync("full-auto", new { target = "enemy" });
 01447    }
 1448
 1449    private async Task UseMedkitAsync()
 1450    {
 01451        if (_raid is null)
 1452        {
 01453            return;
 1454        }
 01455        await ExecuteRaidActionAsync("use-medkit", new { });
 01456    }
 1457
 1458    private async Task ReloadAsync()
 1459    {
 11460        if (!CanReload)
 1461        {
 01462            return;
 1463        }
 11464        await ExecuteRaidActionAsync("reload", new { });
 11465    }
 1466
 1467    private async Task FleeAsync()
 1468    {
 01469        if (_encounterType != EncounterType.Combat)
 1470        {
 01471            return;
 1472        }
 01473        await ExecuteRaidActionAsync("flee", new { });
 01474    }
 1475
 1476
 1477    private Task TakeLootAsync(Item lootItem)
 1478    {
 21479        if (_raid is null)
 1480        {
 01481            return Task.CompletedTask;
 1482        }
 21483        return ExecuteLootActionAsync("take-loot", lootItem, "loot.acquired");
 1484    }
 1485
 1486    private Task DropCarriedAsync(Item item)
 1487    {
 01488        if (_raid is null)
 1489        {
 01490            return Task.CompletedTask;
 1491        }
 01492        return ExecuteRaidActionAsync("drop-carried", new { itemDefId = item.ItemDefId });
 1493    }
 1494
 1495    private Task DropEquippedAsync(ItemType slotType)
 1496    {
 01497        if (_raid is null)
 1498        {
 01499            return Task.CompletedTask;
 1500        }
 01501        return ExecuteRaidActionAsync("drop-equipped", new { slotType = slotType.ToString() });
 1502    }
 1503
 1504    private Task EquipFromDiscoveredAsync(Item item)
 1505    {
 01506        if (_raid is null)
 1507        {
 01508            return Task.CompletedTask;
 1509        }
 01510        return ExecuteLootActionAsync("equip-from-discovered", item, "player.equip");
 1511    }
 1512
 1513    private Task EquipFromCarriedAsync(Item item)
 1514    {
 01515        if (_raid is null)
 1516        {
 01517            return Task.CompletedTask;
 1518        }
 01519        return ExecuteLootActionAsync("equip-from-carried", item, "player.equip");
 1520    }
 1521
 1522    private int GetRaidEncumbrance()
 1523    {
 101524        if (_raid is null)
 1525        {
 01526            return 0;
 1527        }
 1528
 101529        return _raidEncumbrance ?? CombatBalance.GetTotalEncumbrance(_raid.Inventory.GetExtractableItems());
 1530    }
 1531
 1532    private int GetRaidMaxEncumbrance()
 1533    {
 101534        if (_raid is null)
 1535        {
 01536            return 0;
 1537        }
 1538
 101539        return _raidMaxEncumbrance ?? _raid.MaxEncumbrance;
 1540    }
 1541
 1542    private string GetRaidEncumbranceText()
 1543    {
 51544        return $"{GetRaidEncumbrance()}/{GetRaidMaxEncumbrance()} lbs";
 1545    }
 1546
 1547    private static bool IsEquipableRaidItem(Item item)
 1548    {
 31549        return item.Type is ItemType.Weapon or ItemType.Armor or ItemType.Backpack;
 1550    }
 1551
 1552    private bool CanEquipRaidItem(Item item)
 1553    {
 31554        if (_raid is null || !IsEquipableRaidItem(item))
 1555        {
 01556            return false;
 1557        }
 1558
 31559        var carriedItems = CurrentCarriedLoot.ToList();
 41560        var carriedIndex = carriedItems.FindIndex(current => ReferenceEquals(current, item));
 31561        var itemWasCarried = carriedIndex >= 0;
 31562        if (carriedIndex >= 0)
 1563        {
 01564            carriedItems.RemoveAt(carriedIndex);
 1565        }
 31566        var currentEncumbrance = GetRaidEncumbrance();
 31567        var projectedEncumbrance = currentEncumbrance;
 91568        var replacedItem = GetEquippedItems().FirstOrDefault(existing => existing.Type == item.Type);
 31569        if (replacedItem is not null)
 1570        {
 01571            projectedEncumbrance -= Math.Max(0, replacedItem.Weight);
 1572        }
 1573
 31574        if (item.Type == ItemType.Backpack)
 1575        {
 01576            var backpackCapacity = CombatBalance.GetBackpackCapacity(item);
 01577            var currentSlots = carriedItems.Sum(x => x.Slots);
 01578            var spilledWeight = 0;
 01579            while (currentSlots > backpackCapacity && carriedItems.Count > 0)
 1580            {
 01581                var spill = carriedItems[^1];
 01582                carriedItems.RemoveAt(carriedItems.Count - 1);
 01583                currentSlots -= spill.Slots;
 01584                spilledWeight += Math.Max(0, spill.Weight);
 1585            }
 1586
 01587            projectedEncumbrance -= spilledWeight;
 1588        }
 1589
 31590        if (!itemWasCarried)
 1591        {
 31592            projectedEncumbrance += Math.Max(0, item.Weight);
 1593        }
 1594
 31595        return projectedEncumbrance <= GetRaidMaxEncumbrance();
 1596    }
 1597
 1598    private IEnumerable<Item> GetEquippedItems()
 1599    {
 31600        if (_raid?.Inventory.EquippedWeapon is not null)
 1601        {
 31602            yield return _raid.Inventory.EquippedWeapon;
 1603        }
 1604
 31605        if (_raid?.Inventory.EquippedArmor is not null)
 1606        {
 01607            yield return _raid.Inventory.EquippedArmor;
 1608        }
 1609
 31610        if (_raid?.Inventory.EquippedBackpack is not null)
 1611        {
 31612            yield return _raid.Inventory.EquippedBackpack;
 1613        }
 31614    }
 1615
 1616    private async Task AttemptExtractAsync()
 1617    {
 21618        if (_raid is null)
 1619        {
 11620            return;
 1621        }
 11622        await ExecuteRaidActionAsync("attempt-extract", new { });
 21623    }
 1624
 1625    private async Task GoDeeper()
 1626    {
 21627        if (_raid is null)
 1628        {
 11629            return;
 1630        }
 1631
 11632        await ExecuteRaidActionAsync("go-deeper", new { });
 21633    }
 1634
 1635    private async Task MoveTowardExtract()
 1636    {
 21637        if (_raid is null)
 1638        {
 11639            return;
 1640        }
 1641
 11642        await ExecuteRaidActionAsync("move-toward-extract", new { });
 21643    }
 1644
 1645    private async Task StartExtractHoldAsync()
 1646    {
 41647        if (_raid is null || IsExtractHoldEffectivelyActive() || _extractHoldResolutionInFlight)
 1648        {
 21649            return;
 1650        }
 1651
 21652        await ExecuteRaidActionAsync("start-extract-hold", new { });
 41653    }
 1654
 1655    private async Task CancelExtractHoldAsync()
 1656    {
 11657        if (_raid is null || !_extractHoldActive || _extractHoldResolutionInFlight)
 1658        {
 01659            return;
 1660        }
 1661
 11662        await ExecuteRaidActionAsync("cancel-extract-hold", new { });
 11663    }
 1664
 1665    private async Task ResolveExpiredExtractHoldAsync()
 1666    {
 11667        if (_raid is null || !HasExpiredExtractHold() || _extractHoldResolutionInFlight)
 1668        {
 01669            return;
 1670        }
 1671
 11672        _extractHoldResolutionInFlight = true;
 1673        try
 1674        {
 11675            await ExecuteRaidActionAsync("resolve-extract-hold", new
 11676            {
 11677                holdAtExtractUntil = _holdAtExtractUntil?.ToString("O")
 11678            });
 11679        }
 1680        finally
 1681        {
 11682            _extractHoldResolutionInFlight = false;
 1683        }
 11684    }
 1685
 1686    private bool IsExtractHoldEffectivelyActive()
 1687    {
 41688        if (!_extractHoldActive)
 1689        {
 21690            return false;
 1691        }
 1692
 21693        return _holdAtExtractUntil is null || _holdAtExtractUntil > DateTimeOffset.UtcNow;
 1694    }
 1695
 1696    private bool HasExpiredExtractHold()
 1697    {
 11698        return _extractHoldActive
 11699            && _holdAtExtractUntil is not null
 11700            && _holdAtExtractUntil <= DateTimeOffset.UtcNow;
 1701    }
 1702
 1703    private ValueTask ReportHandledErrorAsync(string message, string source, Exception exception, object? context = null
 1704    {
 31705        return Telemetry.ReportErrorAsync(
 31706            message,
 31707            new
 31708            {
 31709                source,
 31710                exception = exception.GetType().FullName,
 31711                exceptionMessage = exception.Message,
 31712                stack = exception.ToString(),
 31713                context
 31714            });
 1715    }
 1716
 1717    private int GetLootSlotCount()
 1718    {
 01719        return _raid?.Inventory.CarriedItems.Sum(x => x.Slots) ?? 0;
 1720    }
 1721
 1722    private int GetReallocateStatCost()
 1723    {
 21724        return (int)Math.Round(_money / 2m, MidpointRounding.AwayFromZero);
 1725    }
 1726
 1727    private string GetReallocateStatCostLabel()
 1728    {
 01729        return $"${GetReallocateStatCost()}";
 1730    }
 1731
 1732    private int GetBuyPrice(Item item)
 1733    {
 01734        return CombatBalance.GetShopPrice(
 01735            CombatBalance.GetBuyPrice(item),
 01736            CombatBalance.GetCharismaModifier(_acceptedStats.Charisma),
 01737            isBuying: true);
 1738    }
 1739
 1740    private bool CanBuyItem(Item item)
 1741    {
 81742        return item.Rarity <= CombatBalance.GetMaxShopRarityFromChaBonus(
 81743            CombatBalance.GetCharismaModifier(_acceptedStats.Charisma));
 1744    }
 1745
 1746    private int GetSellPrice(Item item)
 1747    {
 41748        if (item.ItemDefId == FallbackKnifeItemDefId)
 1749        {
 01750            return 0;
 1751        }
 1752
 41753        return item.Value;
 1754    }
 1755
 1756    private bool CanSellItem(Item item)
 1757    {
 01758        return GetSellPrice(item) > 0;
 1759    }
 1760
 1761    private bool CanLootItem(Item item)
 1762    {
 21763        if (_raid is null)
 1764        {
 01765            return false;
 1766        }
 1767
 21768        var currentEncumbrance = GetRaidEncumbrance();
 21769        if (currentEncumbrance + Math.Max(0, item.Weight) > GetRaidMaxEncumbrance())
 1770        {
 21771            return false;
 1772        }
 1773
 01774        if (item.ItemDefId == MedkitItemDefId)
 1775        {
 01776            return true;
 1777        }
 1778
 01779        var currentSlots = _raid.Inventory.CarriedItems.Sum(x => x.Slots);
 01780        return currentSlots + item.Slots <= _raid.BackpackCapacity;
 1781    }
 1782
 1783    private void EnsureMainCharacterHasWeaponFallback()
 1784    {
 281785        var stashHasWeapon = _mainGame.Stash.Any(item => item.Type == ItemType.Weapon);
 321786        var onPersonHasWeapon = _onPersonItems.Any(entry => entry.Item.Type == ItemType.Weapon);
 201787        if (!stashHasWeapon && !onPersonHasWeapon)
 1788        {
 41789            _mainGame.Stash.Add(ItemCatalog.Create("Rusty Knife"));
 1790        }
 201791    }
 1792
 1793    private void NormalizeEquippedSlots()
 1794    {
 191795        NormalizeEquippedSlot(ItemType.Weapon);
 191796        NormalizeEquippedSlot(ItemType.Armor);
 191797        NormalizeEquippedSlot(ItemType.Backpack);
 191798    }
 1799
 1800    private void NormalizeEquippedSlot(ItemType slotType)
 1801    {
 571802        var found = false;
 1921803        for (var i = 0; i < _onPersonItems.Count; i++)
 1804        {
 391805            var entry = _onPersonItems[i];
 391806            if (entry.Item.Type != slotType || !entry.IsEquipped)
 1807            {
 1808                continue;
 1809            }
 1810
 91811            if (!found)
 1812            {
 91813                found = true;
 91814                continue;
 1815            }
 1816
 01817            _onPersonItems[i] = entry with { IsEquipped = false };
 1818        }
 571819    }
 1820
 1821    private void ApplySnapshot(PlayerSnapshot snapshot)
 1822    {
 141823        _mainGame = new GameState([.. snapshot.MainStash]);
 141824        _randomCharacter = snapshot.RandomCharacter is null
 141825            ? null
 141826            : new RandomCharacterState(snapshot.RandomCharacter.Name, [.. snapshot.RandomCharacter.Inventory], snapshot.
 141827        _randomCharacterAvailableAt = snapshot.RandomCharacterAvailableAt;
 1828
 141829        if (_randomCharacter is not null && _randomCharacter.Inventory.Count == 0)
 1830        {
 21831            _randomCharacter = null;
 1832        }
 1833
 141834        _money = snapshot.Money;
 141835        _shopStock = snapshot.ShopStock
 201836            .Select(offer => ItemCatalog.TryGetByItemDefId(offer.ItemDefId, out var item) && item is not null
 201837                ? new ShopStock(offer, item)
 201838                : null)
 201839            .Where(stock => stock is not null)
 141840            .Cast<ShopStock>()
 141841            .ToList();
 171842        _itemRulesById = snapshot.ItemRules.ToDictionary(rule => rule.ItemDefId);
 141843        _acceptedStats = snapshot.AcceptedStats;
 141844        _draftStats = snapshot.DraftStats;
 141845        _availableStatPoints = snapshot.AvailableStatPoints;
 141846        _statsAccepted = snapshot.StatsAccepted;
 141847        _playerConstitution = snapshot.AcceptedStats.Constitution;
 141848        _maxHealth = snapshot.PlayerMaxHealth;
 141849        _onPersonItems = snapshot.OnPersonItems
 51850            .Select(entry => new OnPersonEntry(entry.Item, entry.IsEquipped))
 141851            .ToList();
 141852        _raidEncumbrance = null;
 141853        _raidMaxEncumbrance = null;
 1854
 141855        if (snapshot.ActiveRaid is null)
 1856        {
 121857            _raid = null;
 121858            _inRaid = false;
 121859            _extractHoldActive = false;
 121860            _holdAtExtractUntil = null;
 121861            _extractHoldResolutionInFlight = false;
 121862            _contactState = string.Empty;
 121863            _surpriseSide = string.Empty;
 121864            _initiativeWinner = string.Empty;
 121865            _openingActionsRemaining = 0;
 121866            _surprisePersistenceEligible = false;
 121867            return;
 1868        }
 1869
 21870        ApplyActiveRaidSnapshot(snapshot.ActiveRaid);
 21871    }
 1872
 1873    private void ApplyActiveRaidSnapshot(RaidSnapshot snapshot)
 1874    {
 41875        var broughtItems = (snapshot.EquippedItems ?? []).ToList();
 41876        var carriedItems = (snapshot.CarriedLoot ?? []).ToList();
 41877        _raid = new RaidState(
 41878            snapshot.Health,
 41879            RaidInventory.FromItems(broughtItems, carriedItems, snapshot.BackpackCapacity));
 41880        _raid.Inventory.DiscoveredLoot.Clear();
 41881        _raid.Inventory.DiscoveredLoot.AddRange(snapshot.DiscoveredLoot ?? []);
 41882        _raid.Inventory.MedkitCount = snapshot.Medkits;
 41883        _raid.Inventory.BackpackCapacity = snapshot.BackpackCapacity;
 41884        _raidEncumbrance = snapshot.Encumbrance > 0 || snapshot.MaxEncumbrance > 0 ? snapshot.Encumbrance : null;
 41885        _raidMaxEncumbrance = snapshot.MaxEncumbrance > 0 ? snapshot.MaxEncumbrance : null;
 41886        _extractHoldActive = snapshot.ExtractHoldActive;
 41887        _holdAtExtractUntil = snapshot.HoldAtExtractUntil;
 41888        if (_raidMaxEncumbrance is not null)
 1889        {
 01890            _raid.MaxEncumbrance = _raidMaxEncumbrance.Value;
 1891        }
 41892        _inRaid = true;
 41893        _awaitingDecision = snapshot.AwaitingDecision;
 41894        _challenge = snapshot.Challenge;
 41895        _distanceFromExtract = snapshot.DistanceFromExtract;
 41896        _ammo = snapshot.Ammo;
 41897        _encounterDescription = RaidPresentationCatalog.GetEncounterDescription(
 41898            snapshot.EncounterDescriptionKey,
 41899            Enum.TryParse<EncounterType>(snapshot.EncounterType, ignoreCase: true, out var snapshotEncounterType)
 41900                ? snapshotEncounterType
 41901                : EncounterType.Neutral,
 41902            snapshot.EncounterDescription,
 41903            snapshot.ExtractHoldActive);
 41904        _contactState = string.IsNullOrWhiteSpace(snapshot.ContactState) ? string.Empty : snapshot.ContactState;
 41905        _surpriseSide = string.IsNullOrWhiteSpace(snapshot.SurpriseSide) ? string.Empty : snapshot.SurpriseSide;
 41906        _initiativeWinner = string.IsNullOrWhiteSpace(snapshot.InitiativeWinner) ? string.Empty : snapshot.InitiativeWin
 41907        _openingActionsRemaining = snapshot.OpeningActionsRemaining;
 41908        _surprisePersistenceEligible = snapshot.SurprisePersistenceEligible;
 41909        _enemyName = RaidPresentationCatalog.GetEnemyLabel(snapshot.EnemyKey, snapshot.EnemyName);
 41910        _enemyHealth = snapshot.EnemyHealth;
 41911        _enemyDexterity = snapshot.EnemyDexterity;
 41912        _enemyConstitution = snapshot.EnemyConstitution;
 41913        _enemyStrength = snapshot.EnemyStrength;
 41914        _lootContainer = snapshot.LootContainer;
 41915        _log.Clear();
 41916        _log.AddRange((snapshot.LogEntries ?? []).Select(RaidPresentationCatalog.LocalizeLogEntry));
 1917
 41918        if (!Enum.TryParse<EncounterType>(snapshot.EncounterType, ignoreCase: true, out var encounterType))
 1919        {
 01920            encounterType = EncounterType.Neutral;
 1921        }
 1922
 41923        _encounterType = encounterType;
 41924    }
 1925
 1926    public void Dispose()
 1927    {
 01928        _clockTimer?.Dispose();
 01929    }
 1930
 1931}

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()