From 1b7654f70f0cdc7b84a96395745159cc944a6ae4 Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Fri, 27 Jun 2025 13:36:28 +0200 Subject: [PATCH] Allow users to load and save builds from Focus View (Closes #1018) (#1021) --- .../Controllers/Rest/MainPlayerController.cs | 12 + Daybreak.API/Daybreak.API.csproj | 2 +- .../Serialization/ApiJsonSerializerContext.cs | 1 + Daybreak.API/Services/MainPlayerService.cs | 47 ++++ Daybreak.Shared/Models/Api/HeroBehavior.cs | 2 +- .../Models/Api/MainPlayerBuildContext.cs | 4 + .../Models/Builds/PartyMemberEntry.cs | 10 + .../Models/FocusView/BuildComponentContext.cs | 9 + .../Models/Guildwars/BuildEntryBase.cs | 2 +- .../Services/Api/ScopedApiContext.cs | 3 + .../BuildTemplates/BuildTemplateManager.cs | 245 ++++++++++++---- .../BuildTemplates/IBuildTemplateManager.cs | 6 + Daybreak/Controls/DropDownButton.cs | 53 ++-- .../FocusViewComponents/BuildComponent.xaml | 172 ++++++++++++ .../BuildComponent.xaml.cs | 264 ++++++++++++++++++ .../Templates/PartyMemberTemplate.xaml | 68 +++++ .../Templates/PartyMemberTemplate.xaml.cs | 71 +++++ Daybreak/Themes/Generic.xaml | 53 ++-- Daybreak/Views/FocusView.xaml | 8 +- Daybreak/Views/FocusView.xaml.cs | 23 +- Daybreak/Views/TeamBuildTemplateView.xaml | 109 ++------ Daybreak/Views/TeamBuildTemplateView.xaml.cs | 56 ++++ 22 files changed, 998 insertions(+), 222 deletions(-) create mode 100644 Daybreak.Shared/Models/Api/MainPlayerBuildContext.cs create mode 100644 Daybreak.Shared/Models/Builds/PartyMemberEntry.cs create mode 100644 Daybreak.Shared/Models/FocusView/BuildComponentContext.cs create mode 100644 Daybreak/Controls/FocusViewComponents/BuildComponent.xaml create mode 100644 Daybreak/Controls/FocusViewComponents/BuildComponent.xaml.cs create mode 100644 Daybreak/Controls/Templates/PartyMemberTemplate.xaml create mode 100644 Daybreak/Controls/Templates/PartyMemberTemplate.xaml.cs diff --git a/Daybreak.API/Controllers/Rest/MainPlayerController.cs b/Daybreak.API/Controllers/Rest/MainPlayerController.cs index 1e72d81c..4f7528a0 100644 --- a/Daybreak.API/Controllers/Rest/MainPlayerController.cs +++ b/Daybreak.API/Controllers/Rest/MainPlayerController.cs @@ -99,4 +99,16 @@ public sealed class MainPlayerController( var titleInfo = await this.mainPlayerService.GetTitleInfo(cancellationToken); return titleInfo is not null ? Results.Ok(titleInfo) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable); } + + [GenerateGet("build-context")] + [EndpointName("GetMainPlayerBuildContext")] + [EndpointSummary("Get the current build context")] + [EndpointDescription("Get the current build context. Returns a json serialized MainPlayerBuildContext object")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task GetMainPlayerBuildContext(CancellationToken cancellationToken) + { + var context = await this.mainPlayerService.GetMainPlayerBuildContext(cancellationToken); + return context is not null ? Results.Ok(context) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } } diff --git a/Daybreak.API/Daybreak.API.csproj b/Daybreak.API/Daybreak.API.csproj index 5fb3f83a..42ea893a 100644 --- a/Daybreak.API/Daybreak.API.csproj +++ b/Daybreak.API/Daybreak.API.csproj @@ -10,7 +10,7 @@ true true true - 0.9.9.73 + 0.9.9.75 true true diff --git a/Daybreak.API/Serialization/ApiJsonSerializerContext.cs b/Daybreak.API/Serialization/ApiJsonSerializerContext.cs index 43443892..9f71393e 100644 --- a/Daybreak.API/Serialization/ApiJsonSerializerContext.cs +++ b/Daybreak.API/Serialization/ApiJsonSerializerContext.cs @@ -35,6 +35,7 @@ namespace Daybreak.API.Serialization; [JsonSerializable(typeof(InstanceInfo))] [JsonSerializable(typeof(TitleInfo))] [JsonSerializable(typeof(LoginInfo))] +[JsonSerializable(typeof(MainPlayerBuildContext))] public partial class ApiJsonSerializerContext : JsonSerializerContext { } diff --git a/Daybreak.API/Services/MainPlayerService.cs b/Daybreak.API/Services/MainPlayerService.cs index 09412ced..119916be 100644 --- a/Daybreak.API/Services/MainPlayerService.cs +++ b/Daybreak.API/Services/MainPlayerService.cs @@ -12,6 +12,7 @@ using System.Collections.Concurrent; using System.Core.Extensions; using System.Extensions; using System.Extensions.Core; +using System.Logging; using System.Runtime.CompilerServices; using ZLinq; using InstanceType = Daybreak.API.Interop.GuildWars.InstanceType; @@ -428,6 +429,52 @@ public sealed class MainPlayerService : IDisposable }, cancellationToken); } + public Task GetMainPlayerBuildContext(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + return this.gameThreadService.QueueOnGameThread(() => + { + unsafe + { + if (this.instanceContextService.GetInstanceType() is InstanceType.Loading) + { + scopedLogger.LogError("Not loaded"); + return default; + } + + var gameContext = this.gameContextService.GetGameContext(); + if (gameContext.IsNull || + gameContext.Pointer->AccountContext is null || + gameContext.Pointer->WorldContext is null) + { + this.logger.LogError("Game context is not initialized"); + return default; + } + + var playerAgentId = this.agentContextService.GetPlayerAgentId(); + if (playerAgentId is 0x0) + { + scopedLogger.LogError("Failed to get player agent id"); + return default; + } + + var agentProfession = gameContext.Pointer->WorldContext->Professions.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerAgentId); + if (agentProfession.AgentId != playerAgentId) + { + scopedLogger.LogError("Failed to find agent profession for player agent id {agentId}", playerAgentId); + return default; + } + + return new MainPlayerBuildContext( + PrimaryProfessionId: (uint)agentProfession.CurrentPrimary, + UnlockedProfessions: agentProfession.UnlockedProfessionsFlags, + UnlockedAccountSkills: gameContext.Pointer->AccountContext->UnlockedAccountSkills.AsValueEnumerable().ToArray(), + UnlockedCharacterSkills: gameContext.Pointer->WorldContext->UnlockedCharacterSkills.AsValueEnumerable().ToArray()); + + } + }, cancellationToken); + } + public CallbackRegistration RegisterMainStateConsumer(TimeSpan frequency, Action> onUpdate) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(frequency, TimeSpan.Zero); diff --git a/Daybreak.Shared/Models/Api/HeroBehavior.cs b/Daybreak.Shared/Models/Api/HeroBehavior.cs index 5c182585..987cc002 100644 --- a/Daybreak.Shared/Models/Api/HeroBehavior.cs +++ b/Daybreak.Shared/Models/Api/HeroBehavior.cs @@ -7,6 +7,6 @@ public enum HeroBehavior { Fight, Guard, - AvoidCombat, + Avoid, Undefined } diff --git a/Daybreak.Shared/Models/Api/MainPlayerBuildContext.cs b/Daybreak.Shared/Models/Api/MainPlayerBuildContext.cs new file mode 100644 index 00000000..1c56b157 --- /dev/null +++ b/Daybreak.Shared/Models/Api/MainPlayerBuildContext.cs @@ -0,0 +1,4 @@ +namespace Daybreak.Shared.Models.Api; +public sealed record MainPlayerBuildContext(uint PrimaryProfessionId, uint UnlockedProfessions, uint[] UnlockedCharacterSkills, uint[] UnlockedAccountSkills) +{ +} diff --git a/Daybreak.Shared/Models/Builds/PartyMemberEntry.cs b/Daybreak.Shared/Models/Builds/PartyMemberEntry.cs new file mode 100644 index 00000000..fae60d4e --- /dev/null +++ b/Daybreak.Shared/Models/Builds/PartyMemberEntry.cs @@ -0,0 +1,10 @@ +using Daybreak.Shared.Models.Api; +using Daybreak.Shared.Models.Guildwars; + +namespace Daybreak.Shared.Models.Builds; +public sealed class PartyMemberEntry +{ + public required SingleBuildEntry Build { get; init; } + public required Hero? Hero { get; init; } + public required HeroBehavior Behavior { get; init; } +} diff --git a/Daybreak.Shared/Models/FocusView/BuildComponentContext.cs b/Daybreak.Shared/Models/FocusView/BuildComponentContext.cs new file mode 100644 index 00000000..1ef542c7 --- /dev/null +++ b/Daybreak.Shared/Models/FocusView/BuildComponentContext.cs @@ -0,0 +1,9 @@ +namespace Daybreak.Shared.Models.FocusView; +public sealed class BuildComponentContext +{ + public required bool IsInOutpost { get; init; } + public required uint PrimaryProfessionId { get; init; } + public required uint UnlockedProfessions { get; init; } + public required uint[] CharacterUnlockedSkills { get; init; } + public required uint[] AccountUnlockedSkills { get; init; } +} diff --git a/Daybreak.Shared/Models/Guildwars/BuildEntryBase.cs b/Daybreak.Shared/Models/Guildwars/BuildEntryBase.cs index c7d081ef..7d2eaa8b 100644 --- a/Daybreak.Shared/Models/Guildwars/BuildEntryBase.cs +++ b/Daybreak.Shared/Models/Guildwars/BuildEntryBase.cs @@ -111,7 +111,7 @@ public abstract class BuildEntryBase : INotifyPropertyChanged, IBuildEntry set { this.Metadata ??= []; - this.Metadata[nameof(PartyCompositionMetadataEntry)] = value is null + this.Metadata[nameof(this.PartyComposition)] = value is null ? string.Empty : JsonConvert.SerializeObject(value, Formatting.None); this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.PartyComposition))); diff --git a/Daybreak.Shared/Services/Api/ScopedApiContext.cs b/Daybreak.Shared/Services/Api/ScopedApiContext.cs index 0dcc7391..5169e024 100644 --- a/Daybreak.Shared/Services/Api/ScopedApiContext.cs +++ b/Daybreak.Shared/Services/Api/ScopedApiContext.cs @@ -25,6 +25,7 @@ public sealed class ScopedApiContext( private const string GetMainPlayerInfoPath = "/api/v1/rest/main-player/info"; private const string GetMainPlayerInstanceInfoPath = "/api/v1/rest/main-player/instance-info"; private const string GetMainPlayerBuildPath = "/api/v1/rest/main-player/build"; + private const string GetMainPlayerBuildContextPath = "/api/v1/rest/main-player/build-context"; private const string PostMainPlayerBuildPath = $"/api/v1/rest/main-player/build?code={CodePlaceholder}"; private const string PartyLoadoutPath = "/api/v1/rest/party/loadout"; private const string GetTitleInfoPath = "/api/v1/rest/main-player/title"; @@ -89,6 +90,8 @@ public sealed class ScopedApiContext( public Task GetProcessId(CancellationToken cancellationToken) => this.GetPayload(GetHealthPath, cancellationToken); + public Task GetMainPlayerBuildContext(CancellationToken cancellationToken) => this.GetPayload(GetMainPlayerBuildContextPath, cancellationToken); + public async Task PostPartyLoadout(PartyLoadout partyLoadout, CancellationToken cancellationToken) => await this.PostPayload(PartyLoadoutPath, partyLoadout, cancellationToken); private async Task PostPayload(string path, T payload, CancellationToken cancellationToken) diff --git a/Daybreak.Shared/Services/BuildTemplates/BuildTemplateManager.cs b/Daybreak.Shared/Services/BuildTemplates/BuildTemplateManager.cs index b769a75b..b3e1726e 100644 --- a/Daybreak.Shared/Services/BuildTemplates/BuildTemplateManager.cs +++ b/Daybreak.Shared/Services/BuildTemplates/BuildTemplateManager.cs @@ -12,6 +12,8 @@ using System.Extensions; using System.Extensions.Core; using System.IO; using System.Linq; +using System.Logging; +using System.Reflection; using System.Text; using System.Threading.Tasks; using AttributeEntry = Daybreak.Shared.Models.Builds.AttributeEntry; @@ -71,6 +73,18 @@ public sealed class BuildTemplateManager( return entry; } + public SingleBuildEntry CreateSingleBuild(BuildEntry buildEntry) + { + var singleBuildEntry = this.CreateSingleBuild(); + return this.PopulateSingleBuild(singleBuildEntry, buildEntry); + } + + public SingleBuildEntry CreateSingleBuild(string name, BuildEntry buildEntry) + { + var singleBuildEntry = this.CreateSingleBuild(name); + return this.PopulateSingleBuild(singleBuildEntry, buildEntry); + } + public TeamBuildEntry CreateTeamBuild() { var name = Guid.NewGuid().ToString(); @@ -139,23 +153,103 @@ public sealed class BuildTemplateManager( return teamBuildEntry; } + public PartyLoadout ConvertToPartyLoadout(TeamBuildEntry teamBuildEntry) + { + if (teamBuildEntry.PartyComposition is not List partyComposition || + partyComposition.Count != teamBuildEntry.Builds.Count) + { + throw new InvalidOperationException("Invalid team build entry. Team build entry contains invalid party composition metadata"); + } + + if (partyComposition.Any(c => c.Type is PartyCompositionMemberType.Hero && (c.HeroId is null || c.Behavior is null))) + { + throw new InvalidOperationException("Invalid team build entry. Hero entries without specified heroes and behaviors are not currently supported"); + } + + var loadoutEntryComposition = teamBuildEntry.Builds.Select((build, index) => + { + var compositionEntry = partyComposition.First(c => c.Index == index); + return (build, compositionEntry, index); + }); + + return new PartyLoadout( + Entries: [.. loadoutEntryComposition.Select(entry => new PartyLoadoutEntry( + HeroId: entry.compositionEntry.Type is PartyCompositionMemberType.MainPlayer ? 0 : entry.compositionEntry.HeroId ?? throw new InvalidOperationException(), + HeroBehavior: entry.compositionEntry.Behavior ?? HeroBehavior.Guard, + Build: this.ConvertToBuildEntry(entry.build)))]); + } + + public BuildEntry ConvertToBuildEntry(SingleBuildEntry singleBuildEntry) + { + return new BuildEntry( + Primary: singleBuildEntry.Primary.Id, + Secondary: singleBuildEntry.Secondary.Id, + Attributes: [.. singleBuildEntry.Attributes.Select(a => new Shared.Models.Api.AttributeEntry((uint)(a.Attribute?.Id ?? -1), (uint)a.Points, (uint)a.Points))], + Skills: [.. singleBuildEntry.Skills.Select(s => (uint)s.Id)]); + } + + public bool CanApply(MainPlayerBuildContext mainPlayerBuildContext, TeamBuildEntry teamBuildEntry) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (teamBuildEntry.PartyComposition is not List partyComposition || + partyComposition.Count != teamBuildEntry.Builds.Count) + { + scopedLogger.LogDebug("Invalid team build entry {buildName}. Team build entry contains invalid party composition metadata", teamBuildEntry.Name ?? string.Empty); + return false; + } + + if (partyComposition.Any(c => c.Type is PartyCompositionMemberType.Hero && (c.HeroId is null || c.Behavior is null))) + { + scopedLogger.LogDebug("Invalid team build entry {buildName}. Hero entries without specified heroes and behaviors are not currently supported", teamBuildEntry.Name ?? string.Empty); + return false; + } + + var loadoutEntryComposition = teamBuildEntry.Builds.Select((build, index) => + { + var compositionEntry = partyComposition.First(c => c.Index == index); + return (build, compositionEntry, index); + }); + + var lockedSkill = loadoutEntryComposition + .Select(entry => entry.build.Skills.FirstOrDefault(s => s != Skill.NoSkill && !IsSkillUnlocked(s.Id, mainPlayerBuildContext.UnlockedAccountSkills))) + .OfType() + .FirstOrDefault(); + if (lockedSkill is not null) + { + scopedLogger.LogDebug("Invalid team build entry {buildName}. Skill {skillName} is not unlocked by the current account", teamBuildEntry.Name ?? string.Empty, lockedSkill.Name); + } + + return loadoutEntryComposition.Any(entry => entry.compositionEntry.Type is PartyCompositionMemberType.MainPlayer && this.CanApply(mainPlayerBuildContext, entry.build)); + } + + public bool CanApply(MainPlayerBuildContext mainPlayerBuildContext, SingleBuildEntry singleBuildEntry) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (singleBuildEntry.Primary.Id != mainPlayerBuildContext.PrimaryProfessionId) + { + scopedLogger.LogDebug("Invalid build entry {buildName}. Build primary {buildPrimaryId} does not match player primary {playerPrimaryId}", singleBuildEntry.Name ?? string.Empty, singleBuildEntry.Primary.Id, mainPlayerBuildContext.PrimaryProfessionId); + return false; + } + + if (singleBuildEntry.Secondary != Profession.None && + !IsProfessionUnlocked(singleBuildEntry.Secondary.Id, mainPlayerBuildContext.UnlockedProfessions)) + { + scopedLogger.LogDebug("Invalid build entry {buildName}. Build secondary {buildSecondaryId} is not unlocked by the player", singleBuildEntry.Name ?? string.Empty, singleBuildEntry.Secondary.Id); + return false; + } + + if (singleBuildEntry.Skills.FirstOrDefault(s => s != Skill.NoSkill && !IsSkillUnlocked(s.Id, mainPlayerBuildContext.UnlockedCharacterSkills)) is Skill lockedSkill) + { + scopedLogger.LogDebug("Invalid build entry {buildName}. Skill {skillName} is not unlocked by the current character", singleBuildEntry.Name ?? string.Empty, lockedSkill.Name); + return false; + } + + return true; + } + public bool CanTemplateApply(BuildTemplateValidationRequest request) { var scopedLogger = this.logger.CreateScopedLogger(); - bool IsProfessionUnlocked(int professionId) => (request.UnlockedCharacterProfessions & (1 << professionId)) != 0; - bool IsSkillUnlocked(int skillId, uint[] unlockedSkills) - { - var realIndex = skillId / 32; - if (realIndex >= unlockedSkills.Length) - { - return false; - } - - var shift = skillId % 32; - var flag = 1U << shift; - return (unlockedSkills[realIndex] & flag) != 0; - } - if (request.BuildPrimary != request.CurrentPrimary) { scopedLogger.LogError("Primary profession does not match current primary profession"); @@ -163,7 +257,7 @@ public sealed class BuildTemplateManager( } if (request.BuildSecondary is not 0 && - !IsProfessionUnlocked((int)request.BuildSecondary)) + !IsProfessionUnlocked((int)request.BuildSecondary, request.UnlockedCharacterProfessions)) { scopedLogger.LogError("Secondary profession is not unlocked"); return false; @@ -389,69 +483,83 @@ public sealed class BuildTemplateManager( private TeamBuildEntry PopulateTeamBuild(TeamBuildEntry teamBuildEntry, PartyLoadout partyLoadout) { - var scopedLogger = this.logger.CreateScopedLogger(); teamBuildEntry.Builds.Clear(); var partyCompositionMetadata = new List(); - var index = 0; - foreach (var entry in partyLoadout.Entries) + foreach (var entry in partyLoadout.Entries.OrderBy(e => e.HeroId)) { var build = entry.Build; var buildEntry = this.CreateSingleBuild(); - if (!Profession.TryParse(build.Primary, out var primary)) - { - scopedLogger.LogError("Failed to parse primary profession with id {professionId}", build.Primary); - throw new InvalidOperationException($"Failed to parse primary profession with id {build.Primary}"); - } + this.PopulateSingleBuild(buildEntry, build); - if (!Profession.TryParse(build.Secondary, out var secondary)) - { - scopedLogger.LogError("Failed to parse secondary profession with id {professionId}", build.Secondary); - throw new InvalidOperationException($"Failed to parse secondary profession with id {build.Secondary}"); - } - - buildEntry.Primary = primary; - buildEntry.Secondary = secondary; - buildEntry.Attributes = [.. build.Attributes.Select(a => - { - if (!Daybreak.Shared.Models.Guildwars.Attribute.TryParse((int)a.Id, out var attr)) - { - scopedLogger.LogError("Failed to parse attribute with id {attributeId}", a.Id); - throw new InvalidOperationException($"Failed to parse attribute with id {a.Id}"); - } - - return new Daybreak.Shared.Models.Builds.AttributeEntry - { - Attribute = attr, - Points = (int)a.BasePoints - }; - })]; - buildEntry.Skills = [.. build.Skills.Select(s => - { - if (!Skill.TryParse((int)s, out var skill)) - { - scopedLogger.LogError("Failed to parse skill with id {skillId}", s); - throw new InvalidOperationException($"Failed to parse skill with id {s}"); - } - - return skill; - })]; - - teamBuildEntry.Builds.Add(buildEntry); - partyCompositionMetadata.Add(new PartyCompositionMetadataEntry + var partyCompositionEntry = new PartyCompositionMetadataEntry { Type = entry.HeroId is 0 ? PartyCompositionMemberType.MainPlayer : PartyCompositionMemberType.Hero, - Index = index, + Index = entry.HeroId is 0 ? 0 : teamBuildEntry.Builds.Count, Behavior = entry.HeroId is 0 ? default : (HeroBehavior)entry.HeroBehavior, HeroId = entry.HeroId is 0 ? default : entry.HeroId - }); + }; - index++; + if (partyCompositionEntry.Type is PartyCompositionMemberType.MainPlayer) + { + teamBuildEntry.Builds.Insert(0, buildEntry); + partyCompositionMetadata.Insert(0, partyCompositionEntry); + } + else + { + teamBuildEntry.Builds.Add(buildEntry); + partyCompositionMetadata.Add(partyCompositionEntry); + } } teamBuildEntry.PartyComposition = partyCompositionMetadata; return teamBuildEntry; } + private SingleBuildEntry PopulateSingleBuild(SingleBuildEntry singleBuildEntry, BuildEntry buildEntry) + { + var scopedLogger = this.logger.CreateScopedLogger(); + if (!Profession.TryParse(buildEntry.Primary, out var primary)) + { + scopedLogger.LogError("Failed to parse primary profession with id {professionId}", buildEntry.Primary); + throw new InvalidOperationException($"Failed to parse primary profession with id {buildEntry.Primary}"); + } + + if (!Profession.TryParse(buildEntry.Secondary, out var secondary)) + { + scopedLogger.LogError("Failed to parse secondary profession with id {professionId}", buildEntry.Secondary); + throw new InvalidOperationException($"Failed to parse secondary profession with id {buildEntry.Secondary}"); + } + + singleBuildEntry.Primary = primary; + singleBuildEntry.Secondary = secondary; + singleBuildEntry.Attributes = [.. buildEntry.Attributes.Select(a => + { + if (!Daybreak.Shared.Models.Guildwars.Attribute.TryParse((int)a.Id, out var attr)) + { + scopedLogger.LogError("Failed to parse attribute with id {attributeId}", a.Id); + throw new InvalidOperationException($"Failed to parse attribute with id {a.Id}"); + } + + return new Daybreak.Shared.Models.Builds.AttributeEntry + { + Attribute = attr, + Points = (int)a.BasePoints + }; + })]; + singleBuildEntry.Skills = [.. buildEntry.Skills.Select(s => + { + if (!Skill.TryParse((int)s, out var skill)) + { + scopedLogger.LogError("Failed to parse skill with id {skillId}", s); + throw new InvalidOperationException($"Failed to parse skill with id {s}"); + } + + return skill; + })]; + + return singleBuildEntry; + } + private async Task> LoadBuildFromFile(string path, string buildName) { if (File.Exists(path) is false) @@ -767,4 +875,19 @@ public sealed class BuildTemplateManager( return (int)value; } + + private static bool IsProfessionUnlocked(int professionId, uint unlockedProfessions) => (unlockedProfessions & (1 << professionId)) != 0; + + private static bool IsSkillUnlocked(int skillId, uint[] unlockedSkills) + { + var realIndex = skillId / 32; + if (realIndex >= unlockedSkills.Length) + { + return false; + } + + var shift = skillId % 32; + var flag = 1U << shift; + return (unlockedSkills[realIndex] & flag) != 0; + } } diff --git a/Daybreak.Shared/Services/BuildTemplates/IBuildTemplateManager.cs b/Daybreak.Shared/Services/BuildTemplates/IBuildTemplateManager.cs index 1e6ab4f4..80d5f461 100644 --- a/Daybreak.Shared/Services/BuildTemplates/IBuildTemplateManager.cs +++ b/Daybreak.Shared/Services/BuildTemplates/IBuildTemplateManager.cs @@ -16,10 +16,16 @@ public interface IBuildTemplateManager bool CanTemplateApply(BuildTemplateValidationRequest request); SingleBuildEntry CreateSingleBuild(); SingleBuildEntry CreateSingleBuild(string name); + SingleBuildEntry CreateSingleBuild(BuildEntry buildEntry); + SingleBuildEntry CreateSingleBuild(string name, BuildEntry buildEntry); TeamBuildEntry CreateTeamBuild(); TeamBuildEntry CreateTeamBuild(string name); TeamBuildEntry CreateTeamBuild(PartyLoadout partyLoadout); TeamBuildEntry CreateTeamBuild(PartyLoadout partyLoadout, string name); + PartyLoadout ConvertToPartyLoadout(TeamBuildEntry teamBuildEntry); + BuildEntry ConvertToBuildEntry(SingleBuildEntry singleBuildEntry); + bool CanApply(MainPlayerBuildContext mainPlayerBuildContext, TeamBuildEntry teamBuildEntry); + bool CanApply(MainPlayerBuildContext mainPlayerBuildContext, SingleBuildEntry singleBuildEntry); void ClearBuilds(); void SaveBuild(IBuildEntry buildEntry); void RemoveBuild(IBuildEntry buildEntry); diff --git a/Daybreak/Controls/DropDownButton.cs b/Daybreak/Controls/DropDownButton.cs index a8832254..63098c29 100644 --- a/Daybreak/Controls/DropDownButton.cs +++ b/Daybreak/Controls/DropDownButton.cs @@ -18,6 +18,7 @@ public partial class DropDownButton : Control private const string PartMainButton = "PART_MainButton"; private const string PartArrowButton = "PART_ArrowButton"; private const string PartDropDown = "PART_DropDown"; + private const string DropDownContextMenu = "DropDownContextMenu"; [GenerateDependencyProperty] private DataTemplate itemTemplate = default!; @@ -72,28 +73,12 @@ public partial class DropDownButton : Control this.arrowButton.Clicked -= this.ArrowButton_Clicked; } - if (this.ContextMenu is not null) - { - this.ContextMenu.Opened -= this.ContextMenu_Opened; - } - - this.mainButton = this.GetTemplateChild(PartMainButton) as HighlightButton; - this.arrowButton = this.GetTemplateChild(PartArrowButton) as HighlightButton; - - if (this.mainButton is not null) - { - this.mainButton.Clicked += this.MainButton_Clicked; - } - - if (this.arrowButton is not null) - { - this.arrowButton.Clicked += this.ArrowButton_Clicked; - } - - if (this.ContextMenu is not null) - { - this.ContextMenu.Opened += this.ContextMenu_Opened; - } + this.ContextMenu ??= (ContextMenu)this.TryFindResource(DropDownContextMenu); + this.mainButton = (HighlightButton)this.GetTemplateChild(PartMainButton); + this.arrowButton = (HighlightButton)this.GetTemplateChild(PartArrowButton); + this.AttachItemClickedHandler(this.ContextMenu); + this.mainButton.Clicked += this.MainButton_Clicked; + this.arrowButton.Clicked += this.ArrowButton_Clicked; } private void MainButton_Clicked(object? sender, object e) @@ -110,19 +95,6 @@ public partial class DropDownButton : Control } } - // When the context‑menu opens, locate the DropDownButtonContextMenu inside it and hook its CLR event. - private void ContextMenu_Opened(object? sender, RoutedEventArgs e) - { - if (sender is not ContextMenu menu) - return; - - if (menu.Template.FindName(PartDropDown, menu) is DropDownButtonContextMenu dropDown) - { - dropDown.ItemClicked -= this.DropDownButtonContextMenu_ItemClicked; // avoid duplicates - dropDown.ItemClicked += this.DropDownButtonContextMenu_ItemClicked; - } - } - private void DropDownButtonContextMenu_ItemClicked(object? _, object e) { this.SelectedItem = e; @@ -133,4 +105,15 @@ public partial class DropDownButton : Control this.SelectionChanged?.Invoke(this, e); } + + private void AttachItemClickedHandler(ContextMenu menu) + { + // Ensure the visual tree is built + menu.ApplyTemplate(); + if (menu.Template.FindName(PartDropDown, menu) is DropDownButtonContextMenu dropDown) + { + dropDown.ItemClicked -= this.DropDownButtonContextMenu_ItemClicked; + dropDown.ItemClicked += this.DropDownButtonContextMenu_ItemClicked; + } + } } diff --git a/Daybreak/Controls/FocusViewComponents/BuildComponent.xaml b/Daybreak/Controls/FocusViewComponents/BuildComponent.xaml new file mode 100644 index 00000000..6055f0c8 --- /dev/null +++ b/Daybreak/Controls/FocusViewComponents/BuildComponent.xaml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Daybreak/Controls/FocusViewComponents/BuildComponent.xaml.cs b/Daybreak/Controls/FocusViewComponents/BuildComponent.xaml.cs new file mode 100644 index 00000000..99548d5c --- /dev/null +++ b/Daybreak/Controls/FocusViewComponents/BuildComponent.xaml.cs @@ -0,0 +1,264 @@ +using Daybreak.Controls.Buttons; +using Daybreak.Shared; +using Daybreak.Shared.Models.Api; +using Daybreak.Shared.Models.Builds; +using Daybreak.Shared.Models.FocusView; +using Daybreak.Shared.Services.Api; +using Daybreak.Shared.Services.BuildTemplates; +using Daybreak.Shared.Services.Navigation; +using Daybreak.Shared.Services.Notifications; +using Daybreak.Views; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Core.Extensions; +using System.Extensions; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Extensions; + +namespace Daybreak.Controls.FocusViewComponents; +/// +/// Interaction logic for BuildComponent.xaml +/// +public partial class BuildComponent : UserControl +{ + private const string PveMetaBuildsUrl = "https://gwpvx.fandom.com/wiki/Category:Great_working_general_builds"; + + private readonly INotificationService notificationService; + private readonly IBuildTemplateManager buildTemplateManager; + private readonly IAttachedApiAccessor attachedApiAccessor; + private readonly IViewManager viewManager; + + private bool buildsInitialized = false; + private List? cachedBuilds; + private MainPlayerBuildContext? cachedMainPlayerBuildContext; + + [GenerateDependencyProperty] + private bool loading; + + [GenerateDependencyProperty] + private bool validData; + + public event EventHandler? NavigateToClicked; + + public ObservableCollection Builds { get; } = []; + + public BuildComponent() + : this(Global.GlobalServiceProvider.GetRequiredService(), + Global.GlobalServiceProvider.GetRequiredService(), + Global.GlobalServiceProvider.GetRequiredService(), + Global.GlobalServiceProvider.GetRequiredService()) + { + } + + public BuildComponent( + INotificationService notificationService, + IViewManager viewManager, + IAttachedApiAccessor attachedApiAccessor, + IBuildTemplateManager buildTemplateManager) + { + this.notificationService = notificationService.ThrowIfNull(); + this.viewManager = viewManager.ThrowIfNull(); + this.attachedApiAccessor = attachedApiAccessor.ThrowIfNull(); + this.buildTemplateManager = buildTemplateManager.ThrowIfNull(); + this.InitializeComponent(); + } + + private async void UserControl_Loaded(object _, RoutedEventArgs __) + { + this.cachedBuilds = await this.buildTemplateManager.GetBuilds().ToListAsync(); + this.buildsInitialized = true; + } + + private void UserControl_Unloaded(object _, RoutedEventArgs __) + { + this.Builds.Clear(); + this.cachedMainPlayerBuildContext = default; + this.buildsInitialized = false; + } + + private void UserControl_DataContextChanged(object _, DependencyPropertyChangedEventArgs e) + { + if (e.NewValue is not BuildComponentContext context) + { + return; + } + + if (!this.BuildContextChanged(context)) + { + return; + } + + this.Loading = true; + this.cachedMainPlayerBuildContext = new MainPlayerBuildContext(context.PrimaryProfessionId, context.UnlockedProfessions, context.CharacterUnlockedSkills, context.AccountUnlockedSkills); + this.LoadBuilds(); + this.Loading = false; + } + + private async void GetPlayerBuildButton_Clicked(object _, EventArgs __) + { + if (this.attachedApiAccessor.ApiContext is not ScopedApiContext apiContext) + { + throw new InvalidOperationException("Attached API context is not available. Focus view should not be accessible without an attached context"); + } + + var buildEntry = await apiContext.GetMainPlayerBuild(CancellationToken.None); + if (buildEntry is null) + { + this.notificationService.NotifyError( + "Failed to load player build", + "Could not retrieve the player's build from the API. Please check logs for more details"); + return; + } + + try + { + var singleBuildEntry = this.buildTemplateManager.CreateSingleBuild(buildEntry); + this.viewManager.ShowView(singleBuildEntry); + } + catch + { + throw; + } + } + + private async void GetTeamBuildButton_Clicked(object _, EventArgs __) + { + if (this.attachedApiAccessor.ApiContext is not ScopedApiContext apiContext) + { + throw new InvalidOperationException("Attached API context is not available. Focus view should not be accessible without an attached context"); + } + + var partyLoadout = await apiContext.GetPartyLoadout(CancellationToken.None); + if (partyLoadout is null) + { + this.notificationService.NotifyError( + "Failed to load team build", + "Could not retrieve the team build from the API. Please check logs for more details"); + return; + } + + try + { + var teamBuildEntry = this.buildTemplateManager.CreateTeamBuild(partyLoadout); + this.viewManager.ShowView(teamBuildEntry); + } + catch + { + throw; + } + } + + private void PveMetaBuildsButton_Clicked(object _, EventArgs __) + { + this.NavigateToClicked?.Invoke(this, PveMetaBuildsUrl); + } + + private async void LoadSingleBuildButton_Clicked(object sender, EventArgs e) + { + if (sender is not BackButton backButton || + backButton.DataContext is not SingleBuildEntry build || + this.attachedApiAccessor.ApiContext is not ScopedApiContext apiContext) + { + return; + } + + this.Loading = true; + var code = this.buildTemplateManager.EncodeTemplate(build); + await apiContext.PostMainPlayerBuild(code, CancellationToken.None).ConfigureAwait(true); + this.Loading = false; + } + + private async void LoadTeamBuildButton_Clicked(object sender, EventArgs e) + { + if (sender is not BackButton backButton || + backButton.DataContext is not TeamBuildEntry build || + this.attachedApiAccessor.ApiContext is not ScopedApiContext apiContext) + { + return; + } + + this.Loading = true; + var loadout = this.buildTemplateManager.ConvertToPartyLoadout(build); + await apiContext.PostPartyLoadout(loadout, CancellationToken.None).ConfigureAwait(true); + this.Loading = false; + } + + private void LoadBuilds() + { + if (this.cachedMainPlayerBuildContext is null) + { + this.Builds.Clear(); + return; + } + + if (this.cachedBuilds is null) + { + this.Builds.Clear(); + return; + } + + var validBuilds = this.cachedBuilds + .Where(build => + { + if (build is SingleBuildEntry singleBuild) + { + return this.buildTemplateManager.CanApply(this.cachedMainPlayerBuildContext, singleBuild); + } + else if (build is TeamBuildEntry teamBuild) + { + return this.buildTemplateManager.CanApply(this.cachedMainPlayerBuildContext, teamBuild); + } + else + { + return false; + } + }) + .OrderBy(b => b.Name); + + this.UpdateBuildCollection(validBuilds); + } + + private void UpdateBuildCollection(IEnumerable validBuilds) + { + var buildsToAdd = validBuilds.Where(b1 => !this.Builds.Any(b2 => b1.Name == b2.Name)).ToList(); + var buildsToRemove = this.Builds.Where(b1 => !validBuilds.Any(b2 => b1.Name == b2.Name)).ToList(); + + foreach(var build in buildsToRemove) + { + this.Builds.Remove(build); + } + + foreach (var build in buildsToAdd) + { + this.Builds.Add(build); + } + } + + private bool BuildContextChanged(BuildComponentContext context) + { + if (!this.buildsInitialized) + { + return false; + } + + if (this.cachedMainPlayerBuildContext is null) + { + return true; + } + else if (this.cachedMainPlayerBuildContext.PrimaryProfessionId != context.PrimaryProfessionId || + this.cachedMainPlayerBuildContext.UnlockedProfessions != context.UnlockedProfessions || + !this.cachedMainPlayerBuildContext.UnlockedCharacterSkills.SequenceEqual(context.CharacterUnlockedSkills) || + !this.cachedMainPlayerBuildContext.UnlockedAccountSkills.SequenceEqual(context.AccountUnlockedSkills)) + { + return true; + } + + return false; + } +} diff --git a/Daybreak/Controls/Templates/PartyMemberTemplate.xaml b/Daybreak/Controls/Templates/PartyMemberTemplate.xaml new file mode 100644 index 00000000..38f98e3a --- /dev/null +++ b/Daybreak/Controls/Templates/PartyMemberTemplate.xaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Daybreak/Controls/Templates/PartyMemberTemplate.xaml.cs b/Daybreak/Controls/Templates/PartyMemberTemplate.xaml.cs new file mode 100644 index 00000000..77b6c519 --- /dev/null +++ b/Daybreak/Controls/Templates/PartyMemberTemplate.xaml.cs @@ -0,0 +1,71 @@ +using Daybreak.Shared.Models.Api; +using Daybreak.Shared.Models.Builds; +using Daybreak.Shared.Models.Guildwars; +using System; +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Extensions; + +namespace Daybreak.Controls.Templates; +/// +/// Interaction logic for PartyMemberTemplate.xaml +/// +public partial class PartyMemberTemplate : UserControl +{ + public event EventHandler? BehaviorChanged; + public event EventHandler? BuildSelected; + + [GenerateDependencyProperty] + private bool showBehavior = false; + + [GenerateDependencyProperty] + private bool showName = false; + + [GenerateDependencyProperty] + private HeroBehavior selectedBehavior; + + public ObservableCollection HeroBehaviors { get; } = + [ + HeroBehavior.Fight, + HeroBehavior.Guard, + HeroBehavior.Avoid + ]; + + public PartyMemberTemplate() + { + this.InitializeComponent(); + } + + private void UserControl_DataContextChanged(object _, DependencyPropertyChangedEventArgs e) + { + if (e.NewValue is not PartyMemberEntry entry) + { + return; + } + + this.ShowBehavior = entry.Hero is not null && entry.Hero != Hero.None; + this.SelectedBehavior = entry.Behavior; + this.ShowName = entry.Hero is not null && entry.Hero != Hero.None; + } + + private void DropDownButton_SelectionChanged(object _, object newValue) + { + if (newValue is not HeroBehavior behavior) + { + return; + } + + this.BehaviorChanged?.Invoke(this, behavior); + } + + private void HighlightButton_Clicked(object sender, EventArgs e) + { + if (this.DataContext is not PartyMemberEntry entry) + { + return; + } + + this.BuildSelected?.Invoke(this, entry.Build); + } +} diff --git a/Daybreak/Themes/Generic.xaml b/Daybreak/Themes/Generic.xaml index 306549fc..457c7b89 100644 --- a/Daybreak/Themes/Generic.xaml +++ b/Daybreak/Themes/Generic.xaml @@ -9,36 +9,31 @@ - + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + - - + + - - - - - - - - - - - - - - - - - - - - - - + - - + + - + Grid.Column="1" + Grid.Row="3" + DataContext="{Binding ElementName=_this, Path=SelectedBuild, Mode=OneWay}" + BuildChanged="BuildTemplate_BuildChanged" /> diff --git a/Daybreak/Views/TeamBuildTemplateView.xaml.cs b/Daybreak/Views/TeamBuildTemplateView.xaml.cs index da07f8d8..0dc17f83 100644 --- a/Daybreak/Views/TeamBuildTemplateView.xaml.cs +++ b/Daybreak/Views/TeamBuildTemplateView.xaml.cs @@ -1,4 +1,6 @@ using Daybreak.Controls.Buttons; +using Daybreak.Controls.Templates; +using Daybreak.Shared.Models.Api; using Daybreak.Shared.Models.Builds; using Daybreak.Shared.Models.Guildwars; using Daybreak.Shared.Services.BuildTemplates; @@ -7,6 +9,7 @@ using Daybreak.Shared.Services.Notifications; using Daybreak.Shared.Services.Toolbox; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using System.Core.Extensions; using System.Extensions; using System.Linq; @@ -40,11 +43,17 @@ public partial class TeamBuildTemplateView : UserControl [GenerateDependencyProperty] private TeamBuildEntry currentBuild = default!; [GenerateDependencyProperty] + private List partyMembers = default!; + [GenerateDependencyProperty] + private PartyMemberEntry selectedPartyMember = default!; + [GenerateDependencyProperty] private string currentBuildCode = string.Empty; [GenerateDependencyProperty] private string currentBuildSource = string.Empty; [GenerateDependencyProperty] private string currentBuildSubCode = string.Empty; + [GenerateDependencyProperty] + private bool isPartyLocked = false; public TeamBuildTemplateView( IToolboxService toolboxService, @@ -69,6 +78,13 @@ public partial class TeamBuildTemplateView : UserControl this.CurrentBuildCode = this.buildTemplateManager.EncodeTemplate(this.CurrentBuild); this.CurrentBuildSource = buildEntry.SourceUrl; this.SelectedBuild = this.CurrentBuild.Builds.FirstOrDefault(); + this.PartyMembers = this.CurrentBuild.PartyComposition?.Select(p => new PartyMemberEntry + { + Behavior = p.Behavior ?? HeroBehavior.Undefined, + Build = this.CurrentBuild.Builds[p.Index], + Hero = Hero.TryParse((int)(p.HeroId ?? -1), out _) ? Hero.Parse((int)p.HeroId!) : default, + }).ToList()!; + this.IsPartyLocked = this.PartyMembers is not null; this.preventDecode = false; this.CurrentBuild.PropertyChanged += this.CurrentBuild_PropertyChanged; } @@ -330,5 +346,45 @@ public partial class TeamBuildTemplateView : UserControl "Copied team build code", $"Copied {this.CurrentBuildCode} code to clipboard"); } + + private void PartyMemberTemplate_BehaviorChanged(object sender, HeroBehavior e) + { + if (sender is not PartyMemberTemplate template || + template.DataContext is not PartyMemberEntry entry) + { + return; + } + + var currentComposition = this.CurrentBuild.PartyComposition ?? []; + var oldComposition = currentComposition.Select((entry, index) => (entry, index)).FirstOrDefault(p => p.entry.HeroId == entry.Hero?.Id); + if (oldComposition.entry.HeroId is null || + oldComposition.entry.HeroId != entry.Hero?.Id) + { + return; + } + + var newComposition = new PartyCompositionMetadataEntry + { + Index = oldComposition.entry.Index, + Type = oldComposition.entry.Type, + HeroId = oldComposition.entry.HeroId, + Behavior = e + }; + currentComposition[oldComposition.index] = newComposition; + this.CurrentBuild.PartyComposition = currentComposition; + } + + private void PartyMemberListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + this.SelectedBuild = this.SelectedPartyMember?.Build; + } + + private void PartyMemberTemplate_BuildSelected(object _, IBuildEntry e) + { + if (e is SingleBuildEntry build) + { + this.SelectedBuild = build; + } + } }