mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-09-15 21:09:22 +00:00
Compare commits
11
Commits
v0.9.10.18
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4720c5e1 | ||
|
|
b57d2ad5d3 | ||
|
|
5faeae64d3 | ||
|
|
fdcf238d68 | ||
|
|
d9f7d05672 | ||
|
|
c61ee2ecd7 | ||
|
|
82512f9461 | ||
|
|
2dc13b7184 | ||
|
|
da691e3310 | ||
|
|
5e020f51d9 | ||
|
|
1f63e59b8a |
@@ -0,0 +1,71 @@
|
||||
# Copilot instructions for Daybreak
|
||||
|
||||
Daybreak is a cross-platform Guild Wars launcher built on .NET 10 and
|
||||
Photino.Blazor. Read the
|
||||
[Architecture Overview](../README.md#architecture-overview) for the project
|
||||
layout and [CONTRIBUTING.md](../CONTRIBUTING.md) for the branching and release
|
||||
process.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Branch off the current `release/[version]` branch, never off `master`.
|
||||
- One feature or fix per branch and per PR.
|
||||
- Never push to `master` and never edit the version in `Directory.Build.props`.
|
||||
Both are owned by the release workflows.
|
||||
- Title PRs `Short description (Closes #123)` so the linked issue gets labelled.
|
||||
|
||||
## Build and test
|
||||
|
||||
CI does not run on PRs targeting a release branch, so validate locally. Run the
|
||||
smallest command that covers the change:
|
||||
|
||||
```bash
|
||||
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows
|
||||
dotnet test Daybreak.Tests/Daybreak.Tests.csproj --filter "FullyQualifiedName~YourTests"
|
||||
```
|
||||
|
||||
Keep the build warning-free.
|
||||
|
||||
## Where code goes
|
||||
|
||||
| Change | Project |
|
||||
| -------------------------------------------------- | ------------------------------------ |
|
||||
| Models, utilities, interfaces shared by everything | `Daybreak.Shared` |
|
||||
| Blazor views and services | `Daybreak.Core` |
|
||||
| Platform-specific implementations | `Daybreak.Windows`, `Daybreak.Linux` |
|
||||
|
||||
`Daybreak.Tests` only references `Daybreak.Core`. When adding platform code, put
|
||||
the logic worth testing in `Daybreak.Shared` and leave the platform project as a
|
||||
thin caller.
|
||||
|
||||
Add NuGet packages through `Directory.Packages.props`.
|
||||
|
||||
## C# conventions
|
||||
|
||||
`.editorconfig` is the source of truth. The projects are set up to treat
|
||||
warnings as errors.
|
||||
|
||||
## Verify before claiming
|
||||
|
||||
Prefer evidence over reasoning about behaviour, especially for the Wine and
|
||||
injection paths. Wine is scriptable, so reproduce the actual failure and confirm
|
||||
the fix against it rather than inferring what a Win32 call does.
|
||||
|
||||
## Writing style
|
||||
|
||||
Applies to docs, comments, PR descriptions and answers.
|
||||
|
||||
- Be short. Cut filler, recap and process narration.
|
||||
- No AI-isms and no inflated language.
|
||||
- Do not paste code into documentation. Link to the file so there is only one
|
||||
copy of it.
|
||||
- Explain the reason for a change, not a summary of the diff.
|
||||
|
||||
## Markdown
|
||||
|
||||
- No inline HTML. Use `![alt][ref]` with a reference definition instead of an
|
||||
`img` tag.
|
||||
- Start a mermaid fence with the diagram type, for example `flowchart LR`. YAML
|
||||
frontmatter inside the fence is rejected by some renderers.
|
||||
- Long URLs belong in reference-style link definitions at the bottom of the
|
||||
file.
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# Contributing to Daybreak
|
||||
|
||||
## Getting started
|
||||
|
||||
Install the prerequisites listed under
|
||||
[Build Requirements](README.md#build-requirements), then clone with submodules:
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/AlexMacocian/Daybreak.git
|
||||
```
|
||||
|
||||
Read the [Architecture Overview](README.md#architecture-overview) before making
|
||||
changes so you put code in the right project.
|
||||
|
||||
## Branching model
|
||||
|
||||
There is always exactly one active release branch, named `release/[version]`
|
||||
(for example `release/0.9.10.20`). It is the integration branch: all work
|
||||
targets it, and `master` only receives finished releases.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph cycle["Release cycle"]
|
||||
direction LR
|
||||
R["release/0.9.10.20<br/><i>active release branch</i>"]
|
||||
F["feature branch<br/><i>one per change</i>"]
|
||||
R -->|fork| F
|
||||
F -->|"PR, squash merge"| R
|
||||
end
|
||||
R -->|"PR, rebase merge"| M["master"]
|
||||
M --> CD["CD pipeline<br/>builds and publishes the release"]
|
||||
CD -->|"job bumps the revision"| N["release/0.9.10.21<br/><i>next active release branch</i>"]
|
||||
```
|
||||
|
||||
### Feature work
|
||||
|
||||
1. Branch off the current release branch.
|
||||
2. Implement one feature or fix. Keep the branch scoped to a single change.
|
||||
3. Open a PR against the release branch.
|
||||
4. Merge with **squash**, so each feature lands as one commit.
|
||||
|
||||
### Releasing
|
||||
|
||||
1. Open a PR from the release branch onto `master`.
|
||||
2. Merge with **rebase**, keeping full history. Each feature stays a single
|
||||
commit on `master`.
|
||||
3. The [CD pipeline](.github/workflows/cd.yaml) runs on the push to `master`,
|
||||
builds the Windows and Linux bundles and publishes a GitHub release.
|
||||
4. On success,
|
||||
[create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml)
|
||||
bumps the revision and creates the next `release/[version]` branch
|
||||
automatically. That becomes the new active release branch.
|
||||
|
||||
Do not push directly to `master` or create release branches by hand.
|
||||
|
||||
## Versions
|
||||
|
||||
The version lives in a single place,
|
||||
[`Directory.Build.props`](Directory.Build.props), and follows
|
||||
`major.minor.build.revision`. It is bumped by the release branch creation job,
|
||||
not manually.
|
||||
|
||||
Revision bumps happen automatically after every release. For the other
|
||||
components, run the matching workflow manually before starting the next cycle:
|
||||
|
||||
| Workflow | Bumps |
|
||||
| ------------------------------------------------------------------------------------------ | --------------------- |
|
||||
| [create-major-release-branch.yml](.github/workflows/create-major-release-branch.yml) | `^.0.0.0` |
|
||||
| [create-minor-release-branch.yml](.github/workflows/create-minor-release-branch.yml) | `*.^.0.0` |
|
||||
| [create-build-release-branch.yml](.github/workflows/create-build-release-branch.yml) | `*.*.^.0` |
|
||||
| [create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml) | `*.*.*.^` (automatic) |
|
||||
|
||||
All four delegate to
|
||||
[create-release-branch-template.yml](.github/workflows/create-release-branch-template.yml).
|
||||
|
||||
## Pull requests
|
||||
|
||||
Title format follows the existing history: a short description, plus the issue
|
||||
reference when it closes one.
|
||||
|
||||
```text
|
||||
Setup stable computer name in Wine prefix (Closes #1609)
|
||||
```
|
||||
|
||||
[label-merged-issues.yaml](.github/workflows/label-merged-issues.yaml) parses
|
||||
`close/fix/resolve #N` from the title and body of PRs merged into a release
|
||||
branch and labels the linked issues, so use those keywords.
|
||||
|
||||
## Checks
|
||||
|
||||
[CI](.github/workflows/ci.yaml) and
|
||||
[version_check.yaml](.github/workflows/version_check.yaml) only trigger on PRs
|
||||
targeting `master`, so a feature PR onto a release branch is not covered by
|
||||
them. Run the equivalent locally before opening one:
|
||||
|
||||
```bash
|
||||
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows on Windows
|
||||
dotnet test Daybreak.Tests/Daybreak.Tests.csproj
|
||||
```
|
||||
|
||||
The version check fails a PR to `master` if the version in
|
||||
`Directory.Build.props` is not ahead of the latest tag. This is why the bump
|
||||
must come from the release branch job.
|
||||
|
||||
## Code style
|
||||
|
||||
Formatting rules are in [`.editorconfig`](.editorconfig) and are enforced by the
|
||||
compiler and analyzers; keep the build warning-free.
|
||||
|
||||
Tests live in [`Daybreak.Tests`](Daybreak.Tests), use MSTest with
|
||||
FluentAssertions and NSubstitute, and only reference `Daybreak.Core`. If you
|
||||
want platform code covered by tests, put the testable part in
|
||||
[`Daybreak.Shared`](Daybreak.Shared).
|
||||
@@ -8977,7 +8977,7 @@ public static unsafe partial class GWCA
|
||||
internal const float Nearby = 252.0f;
|
||||
internal const float Spellcast = 1248.0f;
|
||||
internal const float Spirit = 2512.0f;
|
||||
internal const float SpiritExtended = 3500.0f;
|
||||
internal const float SpiritExtended = 3000.0f;
|
||||
internal const float Touch = 144.0f;
|
||||
}
|
||||
}
|
||||
@@ -9625,11 +9625,11 @@ public static unsafe partial class GWCA
|
||||
kChangeTarget, // 0x10000020, wparam = UIPacket::kChangeTarget*
|
||||
kMessage_0x10000021, // 0x10000021
|
||||
kMessage_0x10000022, // 0x10000022
|
||||
kMessage_0x10000023, // 0x10000023
|
||||
kAgentSkillActivated, // 0x10000024, kAgentSkillPacket
|
||||
kAgentSkillActivatedInstantly, // 0x10000025, kAgentSkillPacket
|
||||
kAgentSkillCancelled, // 0x10000026, kAgentSkillPacket
|
||||
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentStartCasting*
|
||||
kAgentSkillCancelled, // 0x10000023, wparam = kAgentSkillPacket; "<agent> canceled <skill>"
|
||||
kAgentSkillActivated, // 0x10000024, wparam = kAgentSkillPacket; "<agent> executed <skill>"
|
||||
kAgentSkillActivatedInstantly, // 0x10000025, wparam = kAgentSkillPacket; "<agent> used <skill>"
|
||||
kAgentSkillInterrupted, // 0x10000026, wparam = kAgentSkillPacket; "<agent> was interrupted while using <skill>"
|
||||
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentSkillStartedCast*; "<agent> is warming up <skill>"
|
||||
kMessage_0x10000028, // 0x10000028
|
||||
kShowMapEntryMessage, // 0x10000029, wparam = { wchar_t* title, wchar_t* subtitle }
|
||||
kSetCurrentPlayerData, // 0x1000002a, fired after setting the worldcontext player name
|
||||
@@ -15740,10 +15740,10 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
kChangeTarget,
|
||||
kMessage_0x10000021,
|
||||
kMessage_0x10000022,
|
||||
kMessage_0x10000023,
|
||||
kAgentSkillCancelled,
|
||||
kAgentSkillActivated,
|
||||
kAgentSkillActivatedInstantly,
|
||||
kAgentSkillCancelled,
|
||||
kAgentSkillInterrupted,
|
||||
kAgentSkillStartedCast,
|
||||
kMessage_0x10000028,
|
||||
kShowMapEntryMessage,
|
||||
@@ -19702,7 +19702,7 @@ namespace Daybreak.API.Interop.GuildWars
|
||||
public uint AgentId;
|
||||
public global::Daybreak.API.Interop.GWCA.GW.Constants.SkillID SkillId;
|
||||
public float Duration;
|
||||
public uint H000c;
|
||||
public float H000c;
|
||||
}
|
||||
|
||||
[global::System.Runtime.InteropServices.StructLayout(global::System.Runtime.InteropServices.LayoutKind.Sequential, Pack = 1)]
|
||||
|
||||
@@ -415,7 +415,8 @@ public sealed class CharacterSelectService(
|
||||
}
|
||||
|
||||
var availableCharsContext = this.gameContextService.GetAvailableChars();
|
||||
if (availableCharsContext.IsNull)
|
||||
if (!availableCharsContext.IsValid ||
|
||||
!availableCharsContext.Pointer->IsValid)
|
||||
{
|
||||
scopedLogger.LogError("Available characters context is not initialized");
|
||||
return default;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Daybreak.Shared.Attributes;
|
||||
|
||||
namespace Daybreak.Configuration.Options;
|
||||
|
||||
[OptionsName(Name = "Downloads")]
|
||||
internal sealed class DownloadOptions
|
||||
{
|
||||
[JsonPropertyName(nameof(HeaderTimeout))]
|
||||
[OptionName(
|
||||
Name = "Header Timeout",
|
||||
Description = "Amount of seconds Daybreak will wait for a download to respond before giving up on the attempt"
|
||||
)]
|
||||
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
|
||||
public double HeaderTimeout { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName(nameof(ChunkTimeout))]
|
||||
[OptionName(
|
||||
Name = "Chunk Timeout",
|
||||
Description = "Amount of seconds Daybreak will wait for the next chunk of a download before giving up on the attempt"
|
||||
)]
|
||||
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
|
||||
public double ChunkTimeout { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName(nameof(Retries))]
|
||||
[OptionName(
|
||||
Name = "Retries",
|
||||
Description = "Amount of times Daybreak will retry a failed download before reporting it as failed"
|
||||
)]
|
||||
[OptionRange<int>(MinValue = 0, MaxValue = 10)]
|
||||
public int Retries { get; set; } = 3;
|
||||
|
||||
[JsonPropertyName(nameof(RetryDelay))]
|
||||
[OptionName(
|
||||
Name = "Retry Delay",
|
||||
Description = "Amount of seconds Daybreak will wait before retrying a failed download"
|
||||
)]
|
||||
[OptionRange<double>(MinValue = 0, MaxValue = 60)]
|
||||
public double RetryDelay { get; set; } = 2;
|
||||
}
|
||||
@@ -349,6 +349,7 @@ public class ProjectConfiguration : PluginConfigurationBase
|
||||
optionsProducer.RegisterOptions<SynchronizationOptions>();
|
||||
optionsProducer.RegisterOptions<FocusViewOptions>();
|
||||
optionsProducer.RegisterOptions<DaybreakApiOptions>();
|
||||
optionsProducer.RegisterOptions<DownloadOptions>();
|
||||
|
||||
optionsProducer.RegisterOptions<ToolboxOptions>();
|
||||
optionsProducer.RegisterOptions<UModOptions>();
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
using Daybreak.Shared.Models.Async;
|
||||
using Daybreak.Configuration.Options;
|
||||
using Daybreak.Shared.Models.Async;
|
||||
using Daybreak.Shared.Models.Metrics;
|
||||
using Daybreak.Shared.Services.Downloads;
|
||||
using Daybreak.Shared.Services.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Core.Extensions;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Extensions.Core;
|
||||
using System.Logging;
|
||||
|
||||
namespace Daybreak.Services.Downloads;
|
||||
|
||||
internal sealed class DownloadService(
|
||||
IMetricsService metricsService,
|
||||
IHttpClient<DownloadService> httpClient,
|
||||
IOptionsMonitor<DownloadOptions> options,
|
||||
ILogger<DownloadService> logger) : IDownloadService
|
||||
{
|
||||
private const double StatusUpdateInterval = 50;
|
||||
private const int BufferSize = 81920;
|
||||
private const string MetricName = "download.speed";
|
||||
private const string MetricUnits = "bytes/sec";
|
||||
private const string MetricDescription = "Average download speed. Specified in bytes per second";
|
||||
@@ -23,19 +28,79 @@ internal sealed class DownloadService(
|
||||
private readonly static ProgressUpdate ProgressFailed = new(1, "Download failed");
|
||||
private readonly static ProgressUpdate ProgressCompleted = new(1, "Download finished");
|
||||
private static ProgressUpdate ProgressDownload(double progress) => new(progress, "Downloading");
|
||||
private static ProgressUpdate ProgressRetrying(int attempt, int retries) => new(0, $"Download failed. Retrying ({attempt}/{retries})");
|
||||
|
||||
private readonly Histogram<double> averageDownloadSpeed = metricsService.ThrowIfNull().CreateHistogram<double>(MetricName, MetricUnits, MetricDescription, AggregationTypes.NoAggregate);
|
||||
private readonly IHttpClient<DownloadService> httpClient = httpClient.ThrowIfNull();
|
||||
private readonly IOptionsMonitor<DownloadOptions> options = options.ThrowIfNull();
|
||||
private readonly ILogger<DownloadService> logger = logger.ThrowIfNull();
|
||||
|
||||
public async Task<bool> DownloadFile(string downloadUri, string destinationPath, IProgress<ProgressUpdate> progress, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
var currentOptions = this.options.CurrentValue;
|
||||
var retries = Math.Max(0, currentOptions.Retries);
|
||||
var attempts = retries + 1;
|
||||
|
||||
for (var attempt = 1; attempt <= attempts; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
if (await this.TryDownloadFile(downloadUri, destinationPath, progress, currentOptions, cancellationToken))
|
||||
{
|
||||
progress.Report(ProgressCompleted);
|
||||
scopedLogger.LogDebug("Downloaded file");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The caller gave up. The partial file is still removed, because leaving it behind
|
||||
// lets a later run mistake it for a complete download.
|
||||
DeletePartialDownload(destinationPath, scopedLogger);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
scopedLogger.LogError("Download timed out on attempt {Attempt} of {Attempts}", attempt, attempts);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
scopedLogger.LogError(e, "Download failed on attempt {Attempt} of {Attempts}", attempt, attempts);
|
||||
}
|
||||
|
||||
DeletePartialDownload(destinationPath, scopedLogger);
|
||||
if (attempt < attempts)
|
||||
{
|
||||
progress.Report(ProgressRetrying(attempt, retries));
|
||||
if (currentOptions.RetryDelay > 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(currentOptions.RetryDelay), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress.Report(ProgressFailed);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> TryDownloadFile(
|
||||
string downloadUri,
|
||||
string destinationPath,
|
||||
IProgress<ProgressUpdate> progress,
|
||||
DownloadOptions currentOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
progress.Report(ProgressInitialize);
|
||||
using var response = await this.httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
// A server that accepts the connection but never answers would otherwise hang the launch.
|
||||
using var headerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
headerCts.CancelAfter(TimeSpan.FromSeconds(currentOptions.HeaderTimeout));
|
||||
using var response = await this.httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead, headerCts.Token);
|
||||
if (response.IsSuccessStatusCode is false)
|
||||
{
|
||||
progress.Report(ProgressFailed);
|
||||
scopedLogger.LogError($"Failed to download installer. Status: {response.StatusCode}. Details: {await response.Content.ReadAsStringAsync(cancellationToken)}");
|
||||
return false;
|
||||
}
|
||||
@@ -44,30 +109,39 @@ internal sealed class DownloadService(
|
||||
this.logger.LogDebug("Beginning download");
|
||||
var fileInfo = new FileInfo(destinationPath);
|
||||
fileInfo.Directory?.Create();
|
||||
using var fileStream = File.Open(destinationPath, FileMode.Create, FileAccess.Write);
|
||||
var downloadSize = response.Content?.Headers?.ContentLength ?? double.MaxValue;
|
||||
var buffer = new byte[1024];
|
||||
var length = 0;
|
||||
var downloaded = 0d;
|
||||
var downloadedPerTimeframe = 0d;
|
||||
var tickTime = DateTime.Now;
|
||||
var startTime = DateTime.Now;
|
||||
while (downloadStream.CanRead && (length = await downloadStream.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
downloaded += length;
|
||||
downloadedPerTimeframe += length;
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, length), cancellationToken);
|
||||
if ((DateTime.Now - tickTime).TotalMilliseconds > StatusUpdateInterval)
|
||||
{
|
||||
tickTime = DateTime.Now;
|
||||
var downloadedInSecond = downloadedPerTimeframe * 1000d / StatusUpdateInterval;
|
||||
this.averageDownloadSpeed.Record(downloadedInSecond);
|
||||
var chunkTimeout = TimeSpan.FromSeconds(currentOptions.ChunkTimeout);
|
||||
var buffer = new byte[BufferSize];
|
||||
|
||||
// var avgSpeed = downloaded / (tickTime - startTime).TotalSeconds;
|
||||
// var remainingSize = downloadSize - downloaded;
|
||||
// var secondsRemaining = remainingSize / avgSpeed;
|
||||
downloadedPerTimeframe = 0d;
|
||||
progress.Report(ProgressDownload(downloaded / downloadSize));
|
||||
// Scoped so the handle is released before a failed download is deleted.
|
||||
using (var fileStream = File.Open(destinationPath, FileMode.Create, FileAccess.Write))
|
||||
{
|
||||
while (downloadStream.CanRead)
|
||||
{
|
||||
// A stalled connection blocks inside ReadAsync indefinitely, so each chunk gets its own deadline.
|
||||
using var chunkCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
chunkCts.CancelAfter(chunkTimeout);
|
||||
var length = await downloadStream.ReadAsync(buffer, chunkCts.Token);
|
||||
if (length <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
downloaded += length;
|
||||
downloadedPerTimeframe += length;
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, length), cancellationToken);
|
||||
if ((DateTime.Now - tickTime).TotalMilliseconds > StatusUpdateInterval)
|
||||
{
|
||||
tickTime = DateTime.Now;
|
||||
var downloadedInSecond = downloadedPerTimeframe * 1000d / StatusUpdateInterval;
|
||||
this.averageDownloadSpeed.Record(downloadedInSecond);
|
||||
|
||||
downloadedPerTimeframe = 0d;
|
||||
progress.Report(ProgressDownload(downloaded / downloadSize));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,14 +149,24 @@ internal sealed class DownloadService(
|
||||
if (downloadSize != double.MaxValue && downloaded < downloadSize)
|
||||
{
|
||||
scopedLogger.LogError("Download incomplete. Expected {ExpectedSize} bytes but received {ActualSize} bytes", (long)downloadSize, (long)downloaded);
|
||||
progress.Report(ProgressFailed);
|
||||
fileStream.Close();
|
||||
File.Delete(destinationPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
progress.Report(ProgressCompleted);
|
||||
scopedLogger.LogDebug("Downloaded file");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void DeletePartialDownload(string destinationPath, ScopedLogger<DownloadService> scopedLogger)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(destinationPath))
|
||||
{
|
||||
File.Delete(destinationPath);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
scopedLogger.LogError(e, "Failed to delete partial download at {DestinationPath}", destinationPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,80 +8,90 @@
|
||||
<div class="option-view-title">
|
||||
<h3>Accounts</h3>
|
||||
</div>
|
||||
<div class="credentials-list">
|
||||
@foreach (var credential in this.ViewModel.LoginCredentials)
|
||||
{
|
||||
<div class="credential-row">
|
||||
<div class="identifier-row">
|
||||
<div class="identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="identifier-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Identifier"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
ReadOnly="true"
|
||||
Placeholder="Identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Delete account" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username-row">
|
||||
<div class="username-label">
|
||||
<FluentLabel>Username</FluentLabel>
|
||||
</div>
|
||||
<div class="username-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Username"
|
||||
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Immediate="true"
|
||||
ImmediateDelay="500"
|
||||
InputMode="InputMode.Text"
|
||||
TextFieldType="TextFieldType.Email"
|
||||
Placeholder="Username" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="password-row">
|
||||
<div class="password-label">
|
||||
<FluentLabel>Password</FluentLabel>
|
||||
</div>
|
||||
<div class="password-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Password"
|
||||
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Immediate="true"
|
||||
ImmediateDelay="500"
|
||||
InputMode="InputMode.Text"
|
||||
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
|
||||
Placeholder="Password">
|
||||
@if (credential.PasswordVisible)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
|
||||
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
|
||||
@if (this.ViewModel.LoginCredentials.Count is 0)
|
||||
{
|
||||
<EmptyStateWidget Message="No accounts configured"
|
||||
ActionText="Add account"
|
||||
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Person())"
|
||||
OnAction="@this.ViewModel.CreateCredential" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="credentials-list">
|
||||
@foreach (var credential in this.ViewModel.LoginCredentials)
|
||||
{
|
||||
<div class="credential-row">
|
||||
<div class="identifier-row">
|
||||
<div class="identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="identifier-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Identifier"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
ReadOnly="true"
|
||||
Placeholder="Identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Hide password" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
|
||||
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Show password" />
|
||||
}
|
||||
</FluentTextField>
|
||||
Title="Delete account" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username-row">
|
||||
<div class="username-label">
|
||||
<FluentLabel>Username</FluentLabel>
|
||||
</div>
|
||||
<div class="username-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Username"
|
||||
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Immediate="true"
|
||||
ImmediateDelay="500"
|
||||
InputMode="InputMode.Text"
|
||||
TextFieldType="TextFieldType.Email"
|
||||
Placeholder="Username" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="password-row">
|
||||
<div class="password-label">
|
||||
<FluentLabel>Password</FluentLabel>
|
||||
</div>
|
||||
<div class="password-field">
|
||||
<FluentTextField Value="@credential.LoginCredentials.Password"
|
||||
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Immediate="true"
|
||||
ImmediateDelay="500"
|
||||
InputMode="InputMode.Text"
|
||||
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
|
||||
Placeholder="Password">
|
||||
@if (credential.PasswordVisible)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
|
||||
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Hide password" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
|
||||
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
|
||||
slot="end"
|
||||
Color="Color.Neutral"
|
||||
Title="Show password" />
|
||||
}
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<div class="empty-state">
|
||||
<FluentIcon Value="@this.Icon" Color="Color.Neutral" />
|
||||
<div class="empty-message">@this.Message</div>
|
||||
<FluentButton Appearance="Appearance.Accent"
|
||||
Disabled="@this.Disabled"
|
||||
IconStart="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add())"
|
||||
Title="@this.ActionText"
|
||||
OnClick="@this.OnAction">
|
||||
@this.ActionText
|
||||
</FluentButton>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ActionText { get; set; } = "Add";
|
||||
|
||||
[Parameter]
|
||||
public Icon Icon { get; set; } = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Search();
|
||||
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnAction { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--neutral-foreground-hint);
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
font-size: var(--font-size-large);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -8,65 +8,76 @@
|
||||
<div class="option-view-title">
|
||||
<h3>Executables</h3>
|
||||
</div>
|
||||
<div class="executables-list">
|
||||
@foreach (var executable in this.ViewModel.Executables)
|
||||
{
|
||||
<div class="executable-row">
|
||||
<div class="executable-label">
|
||||
<FluentLabel>Path</FluentLabel>
|
||||
</div>
|
||||
<div class="executable-field">
|
||||
<FluentTextField Value="@executable.Path"
|
||||
ReadOnly="true"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Placeholder="Path">
|
||||
<div class="executable-field-controls"
|
||||
slot="end">
|
||||
@if (executable.Validating)
|
||||
{
|
||||
<div class="loading-circle">
|
||||
<SpinnerWidget IsLoading="true" />
|
||||
@if (executable.UpdateProgress is string progress)
|
||||
{
|
||||
<div class="update-progress-text">@progress</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (executable.Valid)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
|
||||
Color="Color.Neutral"
|
||||
Title="Executable is valid" />
|
||||
}
|
||||
else if (executable.NeedsUpdate)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
|
||||
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
|
||||
Color="Color.Neutral"
|
||||
Title="Update executable" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
|
||||
Color="Color.Neutral"
|
||||
Title="Executable is invalid" />
|
||||
}
|
||||
@if (this.ViewModel.Executables.Count is 0)
|
||||
{
|
||||
<EmptyStateWidget Message="No executables configured"
|
||||
ActionText="Add executable"
|
||||
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Apps())"
|
||||
Disabled="@(!this.ViewModel.AddButtonEnabled)"
|
||||
OnAction="@this.ViewModel.CreateExecutable" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="executables-list">
|
||||
@foreach (var executable in this.ViewModel.Executables)
|
||||
{
|
||||
<div class="executable-row">
|
||||
<div class="executable-label">
|
||||
<FluentLabel>Path</FluentLabel>
|
||||
</div>
|
||||
<div class="executable-field">
|
||||
<FluentTextField Value="@executable.Path"
|
||||
ReadOnly="true"
|
||||
Appearance="FluentInputAppearance.Outline"
|
||||
AutoComplete="off"
|
||||
Placeholder="Path">
|
||||
<div class="executable-field-controls"
|
||||
slot="end">
|
||||
@if (executable.Validating)
|
||||
{
|
||||
<div class="loading-circle">
|
||||
<SpinnerWidget IsLoading="true" />
|
||||
@if (executable.UpdateProgress is string progress)
|
||||
{
|
||||
<div class="update-progress-text">@progress</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (executable.Valid)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
|
||||
Color="Color.Neutral"
|
||||
Title="Executable is valid" />
|
||||
}
|
||||
else if (executable.NeedsUpdate)
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
|
||||
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
|
||||
Color="Color.Neutral"
|
||||
Title="Update executable" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
|
||||
Color="Color.Neutral"
|
||||
Title="Executable is invalid" />
|
||||
}
|
||||
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
|
||||
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
|
||||
Color="Color.Neutral"
|
||||
Title="Modify path" />
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
|
||||
Color="Color.Neutral"
|
||||
Title="Remove executable" />
|
||||
</div>
|
||||
</FluentTextField>
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
|
||||
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
|
||||
Color="Color.Neutral"
|
||||
Title="Modify path" />
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
|
||||
Color="Color.Neutral"
|
||||
Title="Remove executable" />
|
||||
</div>
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ public class ExecutablesViewModel(
|
||||
{
|
||||
this.Executables.Remove(executable);
|
||||
this.guildWarsExecutableManager.RemoveExecutable(executable.Path);
|
||||
this.RefreshView();
|
||||
}
|
||||
|
||||
public async Task CreateExecutable()
|
||||
|
||||
@@ -9,151 +9,161 @@
|
||||
<div class="option-view-title">
|
||||
<h3>Launch Configurations</h3>
|
||||
</div>
|
||||
<div class="launch-configs-list">
|
||||
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
|
||||
{
|
||||
var config = this.ViewModel.LaunchConfigurations[i];
|
||||
var index = i;
|
||||
<div class="launch-configs-row" id="launch-config-@config.Identifier">
|
||||
<div class="launch-config-reorder">
|
||||
<div class="launch-config-reorder-label">
|
||||
<FluentLabel>Reorder</FluentLabel>
|
||||
@if (this.ViewModel.LaunchConfigurations.Count is 0)
|
||||
{
|
||||
<EmptyStateWidget Message="No launch configurations yet"
|
||||
ActionText="Add launch configuration"
|
||||
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Rocket())"
|
||||
OnAction="@this.ViewModel.CreateNewLaunchConfiguration" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="launch-configs-list">
|
||||
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
|
||||
{
|
||||
var config = this.ViewModel.LaunchConfigurations[i];
|
||||
var index = i;
|
||||
<div class="launch-configs-row" id="launch-config-@config.Identifier">
|
||||
<div class="launch-config-reorder">
|
||||
<div class="launch-config-reorder-label">
|
||||
<FluentLabel>Reorder</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-reorder-buttons">
|
||||
@if (index > 0)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move up"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
|
||||
</FluentButton>
|
||||
}
|
||||
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move down"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
|
||||
</FluentButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-reorder-buttons">
|
||||
@if (index > 0)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move up"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
|
||||
<div class="launch-config-identifier">
|
||||
<div class="launch-config-identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-identifier-field">
|
||||
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
|
||||
Placeholder="Launch config identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
|
||||
Title="Delete launch configuration" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-name">
|
||||
<div class="launch-config-name-label">
|
||||
<FluentLabel>Name</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-name-field">
|
||||
<FluentTextField Value="@config.Name"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Custom name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-color">
|
||||
<div class="launch-config-color-label">
|
||||
<FluentLabel>Accent Color</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-color-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.AvailableColors"
|
||||
SelectedOption="@(config.Color ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div class="color-option">
|
||||
@if (!string.IsNullOrWhiteSpace(context))
|
||||
{
|
||||
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
|
||||
}
|
||||
<span>@this.ViewModel.GetColorDisplayName(context)</span>
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-executable">
|
||||
<div class="launch-config-executable-label">
|
||||
<FluentLabel>Executable</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-executable-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div title="@(context)">
|
||||
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-credentials">
|
||||
<div class="launch-config-credentials-label">
|
||||
<FluentLabel>Credentials</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-credentials-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
|
||||
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-args">
|
||||
<div class="launch-config-args-label">
|
||||
<FluentLabel>Custom Arguments</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-args-field">
|
||||
<FluentTextField Value="@config.Arguments"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Launch arguments" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-steam">
|
||||
<div class="launch-config-steam-label">
|
||||
<FluentLabel>Steam support</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-steam-field">
|
||||
<FluentCheckbox Value="@config.SteamSupport"
|
||||
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods">
|
||||
<div class="launch-config-custom-mods-label">
|
||||
<FluentLabel>Custom mod loadout</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods-field">
|
||||
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
|
||||
<FluentButton Appearance="Appearance.Neutral"
|
||||
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
|
||||
Disabled="@(!config.CustomModLoadoutEnabled)"
|
||||
Title="Manage custom mods for this launch configuration">
|
||||
Manage Mods
|
||||
</FluentButton>
|
||||
}
|
||||
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
|
||||
{
|
||||
<FluentButton Appearance="Appearance.Stealth"
|
||||
IconOnly="true"
|
||||
Title="Move down"
|
||||
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
|
||||
</FluentButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-identifier">
|
||||
<div class="launch-config-identifier-label">
|
||||
<FluentLabel>Identifier</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-identifier-field">
|
||||
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
|
||||
Placeholder="Launch config identifier">
|
||||
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
|
||||
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
|
||||
Title="Delete launch configuration" />
|
||||
</FluentTextField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-name">
|
||||
<div class="launch-config-name-label">
|
||||
<FluentLabel>Name</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-name-field">
|
||||
<FluentTextField Value="@config.Name"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Custom name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-color">
|
||||
<div class="launch-config-color-label">
|
||||
<FluentLabel>Accent Color</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-color-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.AvailableColors"
|
||||
SelectedOption="@(config.Color ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div class="color-option">
|
||||
@if (!string.IsNullOrWhiteSpace(context))
|
||||
{
|
||||
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
|
||||
}
|
||||
<span>@this.ViewModel.GetColorDisplayName(context)</span>
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-executable">
|
||||
<div class="launch-config-executable-label">
|
||||
<FluentLabel>Executable</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-executable-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
<div title="@(context)">
|
||||
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-credentials">
|
||||
<div class="launch-config-credentials-label">
|
||||
<FluentLabel>Credentials</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-credentials-field">
|
||||
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
|
||||
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
|
||||
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
|
||||
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
|
||||
<OptionTemplate>
|
||||
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
|
||||
</OptionTemplate>
|
||||
</FluentSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-args">
|
||||
<div class="launch-config-args-label">
|
||||
<FluentLabel>Custom Arguments</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-args-field">
|
||||
<FluentTextField Value="@config.Arguments"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
|
||||
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
|
||||
Placeholder="Launch arguments" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-steam">
|
||||
<div class="launch-config-steam-label">
|
||||
<FluentLabel>Steam support</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-steam-field">
|
||||
<FluentCheckbox Value="@config.SteamSupport"
|
||||
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods">
|
||||
<div class="launch-config-custom-mods-label">
|
||||
<FluentLabel>Custom mod loadout</FluentLabel>
|
||||
</div>
|
||||
<div class="launch-config-custom-mods-field">
|
||||
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
|
||||
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
|
||||
<FluentButton Appearance="Appearance.Neutral"
|
||||
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
|
||||
Disabled="@(!config.CustomModLoadoutEnabled)"
|
||||
Title="Manage custom mods for this launch configuration">
|
||||
Manage Mods
|
||||
</FluentButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,22 +57,29 @@ internal static class WineDebugLog
|
||||
/// <c>/bin/sh</c> with stderr appended to <see cref="LogPath"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The command is passed as a single positional parameter and re-parsed with
|
||||
/// <c>eval</c> so the caller's existing double-quoting (paths such as
|
||||
/// <c>"Z:\...\Guild Wars\Gw.exe"</c> contain spaces) keeps working unchanged.
|
||||
/// The command is split here and handed to the shell as separate positional parameters,
|
||||
/// then executed with <c>exec "$@"</c>. The shell must never re-parse the command as source:
|
||||
/// doing so applies a second round of expansion to values Daybreak has already quoted, so a
|
||||
/// launch argument containing <c>$</c>, a backtick or a glob would be rewritten before the
|
||||
/// game ever saw it. Splitting with <see cref="CommandLineUtils.SplitCommandLine"/> keeps the
|
||||
/// argument vector identical to the one .NET builds when the log is disabled.
|
||||
/// </remarks>
|
||||
public static void Apply(ProcessStartInfo startInfo)
|
||||
{
|
||||
var logPath = LogPath;
|
||||
var command = $"\"{startInfo.FileName}\" {startInfo.Arguments}";
|
||||
var arguments = CommandLineUtils.SplitCommandLine(startInfo.Arguments);
|
||||
var fileName = startInfo.FileName;
|
||||
|
||||
startInfo.FileName = "/bin/sh";
|
||||
startInfo.Arguments = string.Empty;
|
||||
startInfo.ArgumentList.Add("-c");
|
||||
startInfo.ArgumentList.Add("log=\"$1\"; shift; eval \"exec $* 2>>'$log'\"");
|
||||
startInfo.ArgumentList.Add("daybreak-wine");
|
||||
startInfo.ArgumentList.Add("exec \"$@\" 2>>\"$0\"");
|
||||
startInfo.ArgumentList.Add(logPath);
|
||||
startInfo.ArgumentList.Add(command);
|
||||
startInfo.ArgumentList.Add(fileName);
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
if (Channels is { } channels)
|
||||
{
|
||||
|
||||
@@ -274,7 +274,7 @@ public sealed class WinePrefixManager(
|
||||
return false;
|
||||
}
|
||||
|
||||
var computerName = Environment.MachineName;
|
||||
var computerName = ComputerNameUtils.SanitizeComputerName(Environment.MachineName);
|
||||
var useDnsComputerNameConfigured = await this.AddRegistryValue(
|
||||
WineNetworkRegistryKey,
|
||||
"UseDnsComputerName",
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace Daybreak.Shared.Models.Guildwars;
|
||||
/// Definition of a Guild Wars skill. The static skill collections (per-campaign
|
||||
/// lists, <see cref="AllSkills"/>, individual <c>public static readonly Skill X</c>
|
||||
/// fields) are generated by <c>Tools/SkillUpdater</c> into <c>Skill.g.cs</c> from
|
||||
/// the official wiki. Re-run the tool with
|
||||
/// <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
|
||||
/// the GWToolbox API, with names and icons from the official wiki. Re-run the
|
||||
/// tool with <c>dotnet run --project Tools/SkillUpdater</c> to refresh.
|
||||
/// </summary>
|
||||
public sealed partial class Skill : IIconUrlEntity
|
||||
{
|
||||
|
||||
+2244
-2308
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
namespace Daybreak.Shared.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for working with Windows style command line strings.
|
||||
/// </summary>
|
||||
public static class CommandLineUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits a Windows style command line into the argument vector it represents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This mirrors the parser .NET applies to <see cref="System.Diagnostics.ProcessStartInfo.Arguments"/>
|
||||
/// when starting a process on Unix. Anything that needs to hand those arguments to another launcher
|
||||
/// must produce the same vector, otherwise the process observes a different command line depending on
|
||||
/// how it was started.
|
||||
/// </remarks>
|
||||
/// <param name="commandLine">The command line to split. May be null or empty.</param>
|
||||
/// <returns>The parsed arguments, with quoting and backslash escapes resolved.</returns>
|
||||
public static List<string> SplitCommandLine(string? commandLine)
|
||||
{
|
||||
var results = new List<string>();
|
||||
if (string.IsNullOrEmpty(commandLine))
|
||||
{
|
||||
return results;
|
||||
}
|
||||
|
||||
var i = 0;
|
||||
while (i < commandLine.Length)
|
||||
{
|
||||
while (i < commandLine.Length && (commandLine[i] is ' ' or '\t'))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i == commandLine.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
results.Add(ReadArgument(commandLine, ref i));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static string ReadArgument(string commandLine, ref int i)
|
||||
{
|
||||
var argument = new System.Text.StringBuilder();
|
||||
var inQuotes = false;
|
||||
|
||||
while (i < commandLine.Length)
|
||||
{
|
||||
var backslashCount = 0;
|
||||
while (i < commandLine.Length && commandLine[i] is '\\')
|
||||
{
|
||||
i++;
|
||||
backslashCount++;
|
||||
}
|
||||
|
||||
if (backslashCount > 0)
|
||||
{
|
||||
if (i >= commandLine.Length || commandLine[i] is not '"')
|
||||
{
|
||||
argument.Append('\\', backslashCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Every pair of backslashes produces one literal backslash. A remaining
|
||||
// backslash escapes the quote that follows it.
|
||||
argument.Append('\\', backslashCount / 2);
|
||||
if (backslashCount % 2 != 0)
|
||||
{
|
||||
argument.Append('"');
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var c = commandLine[i];
|
||||
if (c is '"')
|
||||
{
|
||||
if (inQuotes && i + 1 < commandLine.Length && commandLine[i + 1] is '"')
|
||||
{
|
||||
argument.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && c is ' ' or '\t')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
argument.Append(c);
|
||||
i++;
|
||||
}
|
||||
|
||||
return argument.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Daybreak.Shared.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for producing Windows compatible computer names.
|
||||
/// </summary>
|
||||
public static class ComputerNameUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum length of a Windows NetBIOS computer name, matching the Win32 MAX_COMPUTERNAME_LENGTH constant.
|
||||
/// </summary>
|
||||
public const int MaxComputerNameLength = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Name used when no valid computer name can be derived from the host.
|
||||
/// </summary>
|
||||
public const string FallbackComputerName = "DAYBREAK";
|
||||
|
||||
/// <summary>
|
||||
/// Converts an arbitrary host name into a name that Win32 GetComputerName can return.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// GetComputerNameW is documented to fill a buffer of MAX_COMPUTERNAME_LENGTH + 1 characters, so callers
|
||||
/// size their buffers accordingly. When the configured name is longer, the call fails with
|
||||
/// ERROR_BUFFER_OVERFLOW and leaves the caller's buffer untouched, which applications such as GWToolbox
|
||||
/// observe as an empty computer name. Wine's own wineboot upper-cases and truncates the Linux host name
|
||||
/// for the same reason, so this method reproduces that behaviour.
|
||||
/// </remarks>
|
||||
/// <param name="name">The host name to sanitize.</param>
|
||||
/// <returns>An upper-case, at most <see cref="MaxComputerNameLength"/> characters long, non-empty computer name.</returns>
|
||||
public static string SanitizeComputerName(string? name)
|
||||
{
|
||||
if (name is null)
|
||||
{
|
||||
return FallbackComputerName;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(MaxComputerNameLength);
|
||||
foreach (var c in name)
|
||||
{
|
||||
if (builder.Length >= MaxComputerNameLength)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (char.IsAsciiLetterOrDigit(c))
|
||||
{
|
||||
builder.Append(char.ToUpperInvariant(c));
|
||||
}
|
||||
else if (c is '-' or '_')
|
||||
{
|
||||
builder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
// A computer name may not start or end with a separator.
|
||||
while (builder.Length > 0 && builder[^1] is '-' or '_')
|
||||
{
|
||||
builder.Length--;
|
||||
}
|
||||
|
||||
var sanitized = builder.ToString().TrimStart('-', '_');
|
||||
return sanitized.Length is 0 ? FallbackComputerName : sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Daybreak.Shared.Utils;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Daybreak.Tests.Utils;
|
||||
|
||||
[TestClass]
|
||||
public sealed class CommandLineUtilsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void SplitCommandLine_LaunchArguments_PreservesShellMetacharacters()
|
||||
{
|
||||
var commandLine =
|
||||
"\"Z:\\Injector.exe\" launch True \"Z:\\Games\\Guild Wars\\Gw.exe\" " +
|
||||
"-email \"user@example.com\" -password \"-MyP$SS\" -character \"Daybreak\"";
|
||||
|
||||
CommandLineUtils.SplitCommandLine(commandLine).Should().Equal(
|
||||
"Z:\\Injector.exe",
|
||||
"launch",
|
||||
"True",
|
||||
"Z:\\Games\\Guild Wars\\Gw.exe",
|
||||
"-email",
|
||||
"user@example.com",
|
||||
"-password",
|
||||
"-MyP$SS",
|
||||
"-character",
|
||||
"Daybreak");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("-MyP$SS")]
|
||||
[DataRow("pass`id`word")]
|
||||
[DataRow("pass$(id -u)word")]
|
||||
[DataRow("pass*word")]
|
||||
[DataRow("pass word")]
|
||||
[DataRow("~pass;word&more|x")]
|
||||
[DataRow("$HOME")]
|
||||
public void SplitCommandLine_QuotedValue_IsReturnedVerbatim(string value)
|
||||
{
|
||||
CommandLineUtils.SplitCommandLine($"-password \"{value}\"").Should().Equal("-password", value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SplitCommandLine_QuotedPathWithSpaces_StaysOneArgument()
|
||||
{
|
||||
CommandLineUtils.SplitCommandLine("\"Z:\\Games\\Guild Wars\\Gw.exe\"")
|
||||
.Should().Equal("Z:\\Games\\Guild Wars\\Gw.exe");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SplitCommandLine_WindowsPathTrailingBackslashes_AreLiteral()
|
||||
{
|
||||
CommandLineUtils.SplitCommandLine(@"C:\dir\ next").Should().Equal(@"C:\dir\", "next");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SplitCommandLine_EscapedQuote_IsLiteral()
|
||||
{
|
||||
CommandLineUtils.SplitCommandLine(@"a\""b").Should().Equal("a\"b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SplitCommandLine_DoubledQuoteInsideQuotes_IsLiteral()
|
||||
{
|
||||
CommandLineUtils.SplitCommandLine("\"a\"\"b\"").Should().Equal("a\"b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SplitCommandLine_RepeatedAndTabWhitespace_IsCollapsed()
|
||||
{
|
||||
CommandLineUtils.SplitCommandLine("a \t b").Should().Equal("a", "b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(null)]
|
||||
[DataRow("")]
|
||||
[DataRow(" ")]
|
||||
public void SplitCommandLine_NoArguments_ReturnsEmpty(string? commandLine)
|
||||
{
|
||||
CommandLineUtils.SplitCommandLine(commandLine).Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Daybreak.Shared.Utils;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Daybreak.Tests.Utils;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ComputerNameUtilsTests
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow("zephyrs-cachyos-x8664", "ZEPHYRS-CACHYOS")]
|
||||
[DataRow("bazzite-gaming-rig-01", "BAZZITE-GAMING")]
|
||||
[DataRow("alex-desktop-machine", "ALEX-DESKTOP-MA")]
|
||||
public void SanitizeComputerName_TooLong_TruncatesToMaxLength(string input, string expected)
|
||||
{
|
||||
var result = ComputerNameUtils.SanitizeComputerName(input);
|
||||
|
||||
result.Should().Be(expected);
|
||||
result.Length.Should().BeLessThanOrEqualTo(ComputerNameUtils.MaxComputerNameLength);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("cachyos-x8664", "CACHYOS-X8664")]
|
||||
[DataRow("steamdeck", "STEAMDECK")]
|
||||
[DataRow("ZEPHYRS-CACHYOS", "ZEPHYRS-CACHYOS")]
|
||||
public void SanitizeComputerName_ValidName_UpperCasesAndPreserves(string input, string expected)
|
||||
{
|
||||
ComputerNameUtils.SanitizeComputerName(input).Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("my.host.local", "MYHOSTLOCAL")]
|
||||
[DataRow("hôtel de ville", "HTELDEVILLE")]
|
||||
[DataRow("machine!@#$name", "MACHINENAME")]
|
||||
public void SanitizeComputerName_InvalidCharacters_AreRemoved(string input, string expected)
|
||||
{
|
||||
ComputerNameUtils.SanitizeComputerName(input).Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("--host--", "HOST")]
|
||||
[DataRow("_host_", "HOST")]
|
||||
public void SanitizeComputerName_LeadingOrTrailingSeparators_AreTrimmed(string input, string expected)
|
||||
{
|
||||
ComputerNameUtils.SanitizeComputerName(input).Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SanitizeComputerName_TruncationEndingOnSeparator_TrimsSeparator()
|
||||
{
|
||||
// 'THIS-IS-A-LONG' would be followed by '-' at index 15.
|
||||
ComputerNameUtils.SanitizeComputerName("this-is-a-long--name").Should().Be("THIS-IS-A-LONG");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(null)]
|
||||
[DataRow("")]
|
||||
[DataRow(" ")]
|
||||
[DataRow("...")]
|
||||
[DataRow("---")]
|
||||
public void SanitizeComputerName_NoUsableCharacters_ReturnsFallback(string? input)
|
||||
{
|
||||
ComputerNameUtils.SanitizeComputerName(input).Should().Be(ComputerNameUtils.FallbackComputerName);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SanitizeComputerName_FallbackIsItselfValid()
|
||||
{
|
||||
ComputerNameUtils.FallbackComputerName.Length
|
||||
.Should().BeLessThanOrEqualTo(ComputerNameUtils.MaxComputerNameLength);
|
||||
ComputerNameUtils.SanitizeComputerName(ComputerNameUtils.FallbackComputerName)
|
||||
.Should().Be(ComputerNameUtils.FallbackComputerName);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SanitizeComputerName_CurrentMachineName_IsAlwaysValid()
|
||||
{
|
||||
var result = ComputerNameUtils.SanitizeComputerName(Environment.MachineName);
|
||||
|
||||
result.Should().NotBeNullOrEmpty();
|
||||
result.Length.Should().BeLessThanOrEqualTo(ComputerNameUtils.MaxComputerNameLength);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -234,7 +234,7 @@ namespace GW {
|
||||
constexpr float Earshot = 1012.0f;
|
||||
constexpr float Spellcast = 1248.0f;
|
||||
constexpr float Spirit = 2512.0f;
|
||||
constexpr float SpiritExtended = 3500.0f;
|
||||
constexpr float SpiritExtended = 3000.0f;
|
||||
constexpr float Compass = 5000.0f;
|
||||
};
|
||||
|
||||
|
||||
+11
-11
@@ -166,11 +166,11 @@ namespace GW {
|
||||
kChangeTarget, // 0x10000020, wparam = UIPacket::kChangeTarget*
|
||||
kMessage_0x10000021, // 0x10000021
|
||||
kMessage_0x10000022, // 0x10000022
|
||||
kMessage_0x10000023, // 0x10000023
|
||||
kAgentSkillActivated, // 0x10000024, kAgentSkillPacket
|
||||
kAgentSkillActivatedInstantly, // 0x10000025, kAgentSkillPacket
|
||||
kAgentSkillCancelled, // 0x10000026, kAgentSkillPacket
|
||||
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentStartCasting*
|
||||
kAgentSkillCancelled, // 0x10000023, wparam = kAgentSkillPacket; "<agent> canceled <skill>"
|
||||
kAgentSkillActivated, // 0x10000024, wparam = kAgentSkillPacket; "<agent> executed <skill>"
|
||||
kAgentSkillActivatedInstantly, // 0x10000025, wparam = kAgentSkillPacket; "<agent> used <skill>"
|
||||
kAgentSkillInterrupted, // 0x10000026, wparam = kAgentSkillPacket; "<agent> was interrupted while using <skill>"
|
||||
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentSkillStartedCast*; "<agent> is warming up <skill>"
|
||||
kMessage_0x10000028, // 0x10000028
|
||||
kShowMapEntryMessage, // 0x10000029, wparam = { wchar_t* title, wchar_t* subtitle }
|
||||
kSetCurrentPlayerData, // 0x1000002a, fired after setting the worldcontext player name
|
||||
@@ -674,6 +674,12 @@ namespace GW {
|
||||
uint32_t agent_id;
|
||||
GW::Constants::SkillID skill_id;
|
||||
};
|
||||
struct kAgentSkillStartedCast {
|
||||
uint32_t agent_id;
|
||||
GW::Constants::SkillID skill_id;
|
||||
float duration;
|
||||
float h000c;
|
||||
};
|
||||
struct kLoadMapContext {
|
||||
const wchar_t* file_name;
|
||||
Constants::MapID map_id;
|
||||
@@ -811,12 +817,6 @@ namespace GW {
|
||||
uint32_t h0008;
|
||||
uint32_t h000c;
|
||||
};
|
||||
struct kAgentSkillStartedCast {
|
||||
uint32_t agent_id;
|
||||
Constants::SkillID skill_id;
|
||||
float duration;
|
||||
uint32_t h000c;
|
||||
};
|
||||
struct kPreStartSalvage {
|
||||
uint32_t item_id;
|
||||
uint32_t kit_id;
|
||||
|
||||
+11
-11
@@ -166,11 +166,11 @@ namespace GW {
|
||||
kChangeTarget, // 0x10000020, wparam = UIPacket::kChangeTarget*
|
||||
kMessage_0x10000021, // 0x10000021
|
||||
kMessage_0x10000022, // 0x10000022
|
||||
kMessage_0x10000023, // 0x10000023
|
||||
kAgentSkillActivated, // 0x10000024, kAgentSkillPacket
|
||||
kAgentSkillActivatedInstantly, // 0x10000025, kAgentSkillPacket
|
||||
kAgentSkillCancelled, // 0x10000026, kAgentSkillPacket
|
||||
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentStartCasting*
|
||||
kAgentSkillCancelled, // 0x10000023, wparam = kAgentSkillPacket; "<agent> canceled <skill>"
|
||||
kAgentSkillActivated, // 0x10000024, wparam = kAgentSkillPacket; "<agent> executed <skill>"
|
||||
kAgentSkillActivatedInstantly, // 0x10000025, wparam = kAgentSkillPacket; "<agent> used <skill>"
|
||||
kAgentSkillInterrupted, // 0x10000026, wparam = kAgentSkillPacket; "<agent> was interrupted while using <skill>"
|
||||
kAgentSkillStartedCast, // 0x10000027, wparam = UIPacket::kAgentSkillStartedCast*; "<agent> is warming up <skill>"
|
||||
kMessage_0x10000028, // 0x10000028
|
||||
kShowMapEntryMessage, // 0x10000029, wparam = { wchar_t* title, wchar_t* subtitle }
|
||||
kSetCurrentPlayerData, // 0x1000002a, fired after setting the worldcontext player name
|
||||
@@ -674,6 +674,12 @@ namespace GW {
|
||||
uint32_t agent_id;
|
||||
GW::Constants::SkillID skill_id;
|
||||
};
|
||||
struct kAgentSkillStartedCast {
|
||||
uint32_t agent_id;
|
||||
GW::Constants::SkillID skill_id;
|
||||
float duration;
|
||||
float h000c;
|
||||
};
|
||||
struct kLoadMapContext {
|
||||
const wchar_t* file_name;
|
||||
Constants::MapID map_id;
|
||||
@@ -811,12 +817,6 @@ namespace GW {
|
||||
uint32_t h0008;
|
||||
uint32_t h000c;
|
||||
};
|
||||
struct kAgentSkillStartedCast {
|
||||
uint32_t agent_id;
|
||||
Constants::SkillID skill_id;
|
||||
float duration;
|
||||
uint32_t h000c;
|
||||
};
|
||||
struct kPreStartSalvage {
|
||||
uint32_t item_id;
|
||||
uint32_t kit_id;
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
|
||||
#define GWCA_VERSION_MAJOR 4
|
||||
#define GWCA_VERSION_MINOR 8
|
||||
#define GWCA_VERSION_PATCH 5
|
||||
#define GWCA_VERSION_PATCH 7
|
||||
#define GWCA_VERSION_BUILD 0
|
||||
#define GWCA_VERSION "4.8.5.0"
|
||||
#define GWCA_VERSION "4.8.7.0"
|
||||
|
||||
namespace GWCA {
|
||||
constexpr int VersionMajor = GWCA_VERSION_MAJOR;
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
#include <GWCA/Utilities/Export.h>
|
||||
|
||||
// Guards against headers and binary being from different releases -- struct offsets shift and nothing else catches it.
|
||||
#define GWCA_ABI_VERSION 0x04080500u
|
||||
#define GWCA_ABI_VERSION 0x04080700u
|
||||
|
||||
extern "C" {
|
||||
// The version the binary was built at, against GWCA_ABI_VERSION which is what the caller compiled against.
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -2,7 +2,7 @@
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);IL2104;IL3053;IL3000;IL3002;NU1701;CS0108</NoWarn>
|
||||
|
||||
<Version>0.9.10.18</Version>
|
||||
<Version>0.9.10.21</Version>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Daybreak
|
||||
|
||||
<img width="754" height="716" alt="image" src="https://github.com/user-attachments/assets/3eb74d22-a3ac-4463-8d26-8142cea3d237" />
|
||||
![Daybreak launcher][screenshot]
|
||||
|
||||
Custom launcher for Guild Wars.
|
||||
|
||||
@@ -14,11 +14,18 @@ Custom launcher for Guild Wars.
|
||||
Please check the [wiki](https://github.com/AlexMacocian/Daybreak/wiki) for
|
||||
project description and features.
|
||||
|
||||
---
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the branching model and release
|
||||
process.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Daybreak is a cross-platform application built with .NET 10 and Photino.Blazor. The launcher provides a native UI on each platform while sharing the majority of business logic through a common core library.
|
||||
Daybreak is a cross-platform application built with .NET 10 and Photino.Blazor.
|
||||
The launcher provides a native UI on each platform while sharing the majority of
|
||||
business logic through a common core library.
|
||||
|
||||
### Project Structure
|
||||
|
||||
@@ -56,17 +63,17 @@ graph TB
|
||||
|
||||
### Component Descriptions
|
||||
|
||||
| Project | Description |
|
||||
| ------- | ----------- |
|
||||
| **Daybreak.Windows** | Windows executable with WebView2, MSAL authentication, shortcuts, and native screen management |
|
||||
| **Daybreak.Linux** | Linux executable using GTK/WebKit via Photino, with Wine-based game injection |
|
||||
| **Daybreak.Core** | Shared Blazor UI, services, and configuration (multi-targeted for Windows-specific features) |
|
||||
| **Daybreak.Shared** | Common models, utilities, and interfaces used across all projects |
|
||||
| **Daybreak.Injector** | NativeAOT x86 executable that injects DLLs into the Guild Wars process |
|
||||
| **Daybreak.API** | NativeAOT x86 library injected into Guild Wars, exposes game data via WebSocket/REST |
|
||||
| **Daybreak.Installer** | Standalone installer/updater executable |
|
||||
| Project | Description |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **Daybreak.Windows** | Windows executable with WebView2, MSAL authentication, shortcuts, and native screen management |
|
||||
| **Daybreak.Linux** | Linux executable using GTK/WebKit via Photino, with Wine-based game injection |
|
||||
| **Daybreak.Core** | Shared Blazor UI, services, and configuration (multi-targeted for Windows-specific features) |
|
||||
| **Daybreak.Shared** | Common models, utilities, and interfaces used across all projects |
|
||||
| **Daybreak.Injector** | NativeAOT x86 executable that injects DLLs into the Guild Wars process |
|
||||
| **Daybreak.API** | NativeAOT x86 library injected into Guild Wars, exposes game data via WebSocket/REST |
|
||||
| **Daybreak.Installer** | Standalone installer/updater executable |
|
||||
|
||||
---
|
||||
______________________________________________________________________
|
||||
|
||||
## Build Requirements
|
||||
|
||||
@@ -118,11 +125,13 @@ In addition to .NET, the following are required to run Daybreak on Linux:
|
||||
- **GTK 3** and **WebKitGTK** (for Photino)
|
||||
- **Wine** (for running the Windows-based injector and Guild Wars)
|
||||
|
||||
---
|
||||
______________________________________________________________________
|
||||
|
||||
## Wine Integration (Linux)
|
||||
|
||||
On Linux, Daybreak uses Wine to run the Windows-based `Daybreak.Injector.exe` and Guild Wars itself. This section documents the architecture and implementation plan.
|
||||
On Linux, Daybreak uses Wine to run the Windows-based `Daybreak.Injector.exe`
|
||||
and Guild Wars itself. This section documents the architecture and
|
||||
implementation plan.
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -151,7 +160,8 @@ graph LR
|
||||
|
||||
### Wine Prefix Management
|
||||
|
||||
Daybreak manages a single dedicated Wine prefix for all Wine operations. This is handled by `IWinePrefixManager`:
|
||||
Daybreak manages a single dedicated Wine prefix for all Wine operations. This is
|
||||
handled by `IWinePrefixManager`:
|
||||
|
||||
```csharp
|
||||
public interface IWinePrefixManager
|
||||
@@ -184,11 +194,12 @@ public interface IWinePrefixManager
|
||||
|
||||
### Path Translation
|
||||
|
||||
Wine exposes the Linux filesystem through the `Z:` drive. The `PathUtils` class provides a method to convert native Linux paths to Wine-compatible paths:
|
||||
Wine exposes the Linux filesystem through the `Z:` drive. The `PathUtils` class
|
||||
provides a method to convert native Linux paths to Wine-compatible paths:
|
||||
|
||||
| Linux Path | Wine Path |
|
||||
|------------|-----------|
|
||||
| `/mnt/games/Guild Wars/Gw.exe` | `Z:/mnt/games/Guild Wars/Gw.exe` |
|
||||
| Linux Path | Wine Path |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `/mnt/games/Guild Wars/Gw.exe` | `Z:/mnt/games/Guild Wars/Gw.exe` |
|
||||
| `/home/user/.daybreak/Injector/Daybreak.Injector.exe` | `Z:/home/user/.daybreak/Injector/Daybreak.Injector.exe` |
|
||||
|
||||
```csharp
|
||||
@@ -202,36 +213,47 @@ public static string ToWinePath(string linuxPath)
|
||||
|
||||
### Wine Debug Log
|
||||
|
||||
Debug builds append all Wine output to `wine-debug.log` in the Daybreak output folder:
|
||||
Debug builds append all Wine output to `wine-debug.log` in the Daybreak output
|
||||
folder:
|
||||
|
||||
```
|
||||
```text
|
||||
Daybreak.Linux/bin/Debug/net10.0/linux-x64/wine-debug.log
|
||||
```
|
||||
|
||||
This is the place to look when Guild Wars dies without an error. Guild Wars is a grandchild of the Wine process Daybreak starts, so its stderr would otherwise go to a pipe nobody reads and the crash report would be lost. Wine runs `winedbg --auto` on a crash, so the log contains the faulting stack — including managed frames when `Daybreak.API` is at fault:
|
||||
This is the place to look when Guild Wars dies without an error. Guild Wars is a
|
||||
grandchild of the Wine process Daybreak starts, so its stderr would otherwise go
|
||||
to a pipe nobody reads and the crash report would be lost. Wine runs
|
||||
`winedbg --auto` on a crash, so the log contains the faulting stack — including
|
||||
managed frames when `Daybreak.API` is at fault:
|
||||
|
||||
```
|
||||
```text
|
||||
err:eventlog:ReportEventW L"Message: Access Violation: Attempted to read or write protected memory..."
|
||||
err:eventlog:ReportEventW L" at Daybreak.API.Interop.GuildWars.GuildWarsArray`1.Enumerator.get_Current()"
|
||||
err:eventlog:ReportEventW L" at Daybreak.API.Services.CharacterSelectService...
|
||||
err:seh:NtRaiseException Unhandled exception code c0000409
|
||||
```
|
||||
|
||||
Note that `Daybreak.API` is NativeAOT, which cannot throw `AccessViolationException`. A bad pointer read therefore calls `FailFast` and terminates Guild Wars instantly, with nothing written to `Daybreak.API.log`.
|
||||
Note that `Daybreak.API` is NativeAOT, which cannot throw
|
||||
`AccessViolationException`. A bad pointer read therefore calls `FailFast` and
|
||||
terminates Guild Wars instantly, with nothing written to `Daybreak.API.log`.
|
||||
|
||||
Each launch is delimited by a `===== <timestamp> <command> =====` header. The log is append-only, so delete it when it gets large.
|
||||
Each launch is delimited by a `===== <timestamp> <command> =====` header. The
|
||||
log is append-only, so delete it when it gets large.
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `DAYBREAK_WINE_DEBUG` | `1` forces logging on, `0` off. Defaults to on for Debug builds, off for Release. |
|
||||
| Variable | Effect |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DAYBREAK_WINE_DEBUG` | `1` forces logging on, `0` off. Defaults to on for Debug builds, off for Release. |
|
||||
| `DAYBREAK_WINE_DEBUG_CHANNELS` | Sets `WINEDEBUG`, e.g. `+seh`. Unset by default — Wine's default `err` class already reports crashes, and trace channels slow the game badly. |
|
||||
|
||||
---
|
||||
______________________________________________________________________
|
||||
|
||||
## Credits
|
||||
|
||||
- Daybreak project is distributed under [MIT license](https://mit-license.org/)
|
||||
- Tango icons - [LordBiro](https://wiki.guildwars.com/wiki/User:LordBiro)
|
||||
- Icons `Daybreak/wwwroot/img/tango` are distributed under [GFDL license](https://en.wikipedia.org/wiki/GNU_Free_Documentation_License)
|
||||
- Icons `Daybreak/wwwroot/img/tango` are distributed under
|
||||
[GFDL license](https://en.wikipedia.org/wiki/GNU_Free_Documentation_License)
|
||||
|
||||
[screenshot]: https://github.com/user-attachments/assets/3eb74d22-a3ac-4463-8d26-8142cea3d237
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the GWToolbox API's skill catalogue — the single source of truth
|
||||
/// for every skill *value* the generator emits. One request; no throttling
|
||||
/// needed, unlike <see cref="WikiHttpClient"/>.
|
||||
/// </summary>
|
||||
internal sealed class GwToolboxClient(string userAgent) : IDisposable
|
||||
{
|
||||
private const string SkillsUrl = "https://api.gwtoolbox.com/v1/en/skills.json";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient httpClient = CreateHttpClient(userAgent);
|
||||
|
||||
private static HttpClient CreateHttpClient(string userAgent)
|
||||
{
|
||||
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>Fetches the catalogue indexed by skill id.</summary>
|
||||
public async Task<GwToolboxCatalog> FetchSkillsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = await this.httpClient.GetStreamAsync(SkillsUrl, cancellationToken);
|
||||
var skills = await JsonSerializer.DeserializeAsync<List<GwToolboxSkill>>(stream, SerializerOptions, cancellationToken)
|
||||
?? throw new InvalidOperationException($"GET {SkillsUrl} returned no skills.");
|
||||
|
||||
return new GwToolboxCatalog(skills);
|
||||
}
|
||||
|
||||
public void Dispose() => this.httpClient.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The API's skills indexed for lookup by id, and — for the wiki pages that
|
||||
/// omit the id — by name.
|
||||
/// </summary>
|
||||
internal sealed partial class GwToolboxCatalog
|
||||
{ private readonly Dictionary<int, GwToolboxSkill> byId;
|
||||
private readonly Dictionary<string, List<GwToolboxSkill>> byName;
|
||||
|
||||
public GwToolboxCatalog(IReadOnlyList<GwToolboxSkill> skills)
|
||||
{
|
||||
this.byId = new Dictionary<int, GwToolboxSkill>(skills.Count);
|
||||
this.byName = new Dictionary<string, List<GwToolboxSkill>>(StringComparer.Ordinal);
|
||||
foreach (var skill in skills)
|
||||
{
|
||||
this.byId[skill.Id] = skill;
|
||||
|
||||
var name = NormalizeName(skill.Name);
|
||||
if (name.Length == 0 || name == "(none)")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.byName.TryGetValue(name, out var bucket))
|
||||
{
|
||||
this.byName[name] = bucket = [];
|
||||
}
|
||||
|
||||
bucket.Add(skill);
|
||||
}
|
||||
}
|
||||
|
||||
public int Count => this.byId.Count;
|
||||
|
||||
public bool TryGetById(int id, out GwToolboxSkill skill) => this.byId.TryGetValue(id, out skill!);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a skill by name, but only when the name identifies exactly one
|
||||
/// — the client reuses names freely, so anything else is ambiguous.
|
||||
/// </summary>
|
||||
public bool TryGetUniqueByName(string name, out GwToolboxSkill skill)
|
||||
{
|
||||
skill = null!;
|
||||
if (!this.byName.TryGetValue(NormalizeName(name), out var bucket) || bucket.Count != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
skill = bucket[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches the wiki's convention: collapsed whitespace (some API names
|
||||
/// carry a double space before a "(PvP)" suffix) and no surrounding quotes
|
||||
/// (which the API keeps on shouts and the wiki page titles do not).
|
||||
/// </summary>
|
||||
private static string NormalizeName(string? name)
|
||||
{
|
||||
var trimmed = WhitespaceRegex().Replace(name ?? string.Empty, " ").Trim();
|
||||
return trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"'
|
||||
? trimmed[1..^1]
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// One record of <c>https://api.gwtoolbox.com/v1/en/skills.json</c>, scanned
|
||||
/// straight out of the Guild Wars client. Only the fields the generator needs
|
||||
/// are modelled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The API omits any key equal to its default (<c>0</c>, <c>""</c>, <c>null</c>)
|
||||
/// to keep the payload small, so every field here is nullable and a missing key
|
||||
/// must be read as its zero value — see the "Omitted default-valued keys"
|
||||
/// section of the API's README.
|
||||
/// </remarks>
|
||||
internal sealed class GwToolboxSkill
|
||||
{
|
||||
[JsonPropertyName("id")] public int Id { get; init; }
|
||||
|
||||
[JsonPropertyName("name")] public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("description")] public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("concise")] public string? Concise { get; init; }
|
||||
|
||||
[JsonPropertyName("campaign")] public int Campaign { get; init; }
|
||||
|
||||
[JsonPropertyName("profession")] public int Profession { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>GW::Constants::AttributeByte</c>, absent when the skill has no
|
||||
/// governing attribute. Id 0 (Fast Casting) is emitted explicitly, so a
|
||||
/// missing key is "none" rather than "Fast Casting".
|
||||
/// </summary>
|
||||
[JsonPropertyName("attribute")] public int? Attribute { get; init; }
|
||||
|
||||
/// <summary><c>GW::Constants::SkillType</c> — a single value, not a bitmask.</summary>
|
||||
[JsonPropertyName("type")] public int Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Encoded cost rather than the literal one: values up to 10 are the cost
|
||||
/// itself, 11 means 15 and 12 means 25.
|
||||
/// </summary>
|
||||
[JsonPropertyName("energy_cost")] public int EnergyCost { get; init; }
|
||||
|
||||
/// <summary>Adrenaline in the client's internal units; 25 per strike.</summary>
|
||||
[JsonPropertyName("adrenaline")] public int Adrenaline { get; init; }
|
||||
|
||||
[JsonPropertyName("overcast")] public int Overcast { get; init; }
|
||||
|
||||
/// <summary>Sacrificed health as a whole percentage (e.g. 17 for 17%).</summary>
|
||||
[JsonPropertyName("health_cost")] public int HealthCost { get; init; }
|
||||
|
||||
[JsonPropertyName("activation")] public double Activation { get; init; }
|
||||
|
||||
[JsonPropertyName("recharge")] public double Recharge { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Effect duration at 0 ranks, in game time units.
|
||||
/// <see cref="SkillMapper.MaintainedDuration"/> is the client's sentinel for
|
||||
/// a maintained enchantment, which is what Daybreak models as upkeep.
|
||||
/// </summary>
|
||||
[JsonPropertyName("duration0")] public int Duration0 { get; init; }
|
||||
|
||||
/// <summary>Raw weapon-type requirement bitmask; see <see cref="SkillMapper"/>.</summary>
|
||||
[JsonPropertyName("weapon_req")] public int WeaponRequirement { get; init; }
|
||||
|
||||
/// <summary>Dagger attack-chain position: 1 lead, 2 off-hand, 3 dual.</summary>
|
||||
[JsonPropertyName("combo")] public int Combo { get; init; }
|
||||
|
||||
[JsonPropertyName("elite")] public int Elite { get; init; }
|
||||
|
||||
[JsonPropertyName("touch_range")] public int TouchRange { get; init; }
|
||||
|
||||
[JsonPropertyName("pve_only")] public int PvEOnly { get; init; }
|
||||
|
||||
[JsonPropertyName("pvp_only")] public int PvPOnly { get; init; }
|
||||
}
|
||||
@@ -29,7 +29,7 @@ internal sealed class IconResolver(WikiHttpClient client)
|
||||
private const int BatchSize = 25;
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> ResolveAsync(
|
||||
IReadOnlyList<ParsedSkill> skills,
|
||||
IReadOnlyList<WikiSkillEntry> skills,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Track candidate (skillName, filename) pairs across passes; a skill
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// A skill in the exact shape the writer will emit it: every value is already
|
||||
/// the literal C# token it should appear as in <c>Skill.g.cs</c>. This is
|
||||
/// deliberately tool-specific — the runtime <c>WikiService</c> has its own
|
||||
/// parser in <c>Daybreak.Shared</c> built around typed model objects.
|
||||
/// </summary>
|
||||
public sealed record ParsedSkill(
|
||||
int Id,
|
||||
string Name,
|
||||
string CampaignIdentifier,
|
||||
string ProfessionIdentifier,
|
||||
string AttributeIdentifier,
|
||||
bool PvEOnly,
|
||||
bool PvP,
|
||||
bool Elite,
|
||||
string TypeExpression,
|
||||
double? Energy,
|
||||
double? Activation,
|
||||
double? Recharge,
|
||||
double? Overcast,
|
||||
double? Adrenaline,
|
||||
double? Sacrifice,
|
||||
double? Upkeep,
|
||||
string Description,
|
||||
string ConciseDescription);
|
||||
@@ -24,23 +24,65 @@ internal static class Program
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new WikiHttpClient(UserAgent);
|
||||
var enumerator = new SkillEnumerator(client);
|
||||
using var wikiClient = new WikiHttpClient(UserAgent);
|
||||
using var apiClient = new GwToolboxClient(UserAgent);
|
||||
|
||||
Console.WriteLine("Enumerating skills from wiki…");
|
||||
var skills = await enumerator.EnumerateAsync(cancellationSource.Token);
|
||||
Console.WriteLine($"Collected {skills.Count} skills.");
|
||||
Console.WriteLine("Fetching skill data from api.gwtoolbox.com…");
|
||||
var apiSkills = await apiClient.FetchSkillsAsync(cancellationSource.Token);
|
||||
Console.WriteLine($"Fetched {apiSkills.Count} skill records.");
|
||||
Console.WriteLine();
|
||||
|
||||
var iconResolver = new IconResolver(client);
|
||||
var iconUrls = await iconResolver.ResolveAsync(skills, cancellationSource.Token);
|
||||
var enumerator = new SkillEnumerator(wikiClient);
|
||||
Console.WriteLine("Enumerating the skill roster from the wiki…");
|
||||
var roster = await enumerator.EnumerateAsync(cancellationSource.Token);
|
||||
Console.WriteLine($"Collected {roster.Count} skill pages.");
|
||||
Console.WriteLine();
|
||||
|
||||
var skills = new List<ParsedSkill>();
|
||||
var warnings = new List<string>();
|
||||
foreach (var entry in roster)
|
||||
{
|
||||
// Monster and environment skill pages routinely omit the id
|
||||
// from their infobox. The API still knows them, so fall back to
|
||||
// its name index rather than emitting an unusable id-0 entry.
|
||||
if (entry.Ids.Count == 0)
|
||||
{
|
||||
if (apiSkills.TryGetUniqueByName(entry.Name, out var byName))
|
||||
{
|
||||
skills.Add(SkillMapper.Map(byName.Id, entry.Name, byName));
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add($"skipped '{entry.Name}': the wiki lists no id and the name is not unique in the API");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var id in entry.Ids)
|
||||
{
|
||||
if (!apiSkills.TryGetById(id, out var apiSkill))
|
||||
{
|
||||
warnings.Add($"skipped '{entry.Name}' (id {id}): no record in the API");
|
||||
continue;
|
||||
}
|
||||
|
||||
skills.Add(SkillMapper.Map(id, entry.Name, apiSkill));
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Mapped {skills.Count} skills from API data.");
|
||||
Console.WriteLine();
|
||||
|
||||
var iconResolver = new IconResolver(wikiClient);
|
||||
var iconUrls = await iconResolver.ResolveAsync(roster, cancellationSource.Token);
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("Rendering Skill.g.cs…");
|
||||
var (content, warnings) = SkillFileWriter.Render(skills, iconUrls);
|
||||
var (content, renderWarnings) = SkillFileWriter.Render(skills, iconUrls);
|
||||
await File.WriteAllTextAsync(skillFile, content, cancellationSource.Token);
|
||||
Console.WriteLine($"Wrote {content.Length:N0} chars to {skillFile}");
|
||||
foreach (var warn in warnings)
|
||||
foreach (var warn in renderWarnings.Concat(warnings))
|
||||
{
|
||||
Console.Error.WriteLine($" ! {warn}");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# SkillUpdater
|
||||
|
||||
One-shot console tool that regenerates `Daybreak.Shared/Models/Guildwars/Skill.g.cs`
|
||||
from <https://wiki.guildwars.com>. The wiki is the single source of truth for skill
|
||||
data — this tool does not read the existing `Skill.g.cs`.
|
||||
One-shot console tool that regenerates `Daybreak.Shared/Models/Guildwars/Skill.g.cs`.
|
||||
Skill *values* come from the GWToolbox API
|
||||
(<https://api.gwtoolbox.com/v1/en/skills.json>), which is generated straight from
|
||||
the Guild Wars client; <https://wiki.guildwars.com> supplies only the roster and
|
||||
the icon URLs. This tool does not read the existing `Skill.g.cs`.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -10,16 +12,52 @@ data — this tool does not read the existing `Skill.g.cs`.
|
||||
dotnet run --project Tools/SkillUpdater
|
||||
```
|
||||
|
||||
## Why two sources
|
||||
|
||||
The API is the source of truth for every value because it is the client's own
|
||||
data: descriptions arrive already rendered ("20...44...50% faster") instead of
|
||||
as wiki markup the generator has to interpret (`{{gr|20|50||%}}`), and the
|
||||
numbers behind them are exact.
|
||||
|
||||
The wiki is kept for two things the API cannot provide:
|
||||
|
||||
- **The roster.** The client's skill names are not unique — four different
|
||||
skills are called "Charm Animal", and ~1,600 unreleased/internal skills sit
|
||||
alongside the live ones. The wiki disambiguates them ("Charm Animal (White
|
||||
Mantle)"), and those unique names are what the generated C# identifiers and
|
||||
the icon lookup are keyed on.
|
||||
- **The icons.** The API serves DDS textures; Daybreak's builder and UI load the
|
||||
wiki's JPEGs dynamically.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Enumerates every skill page via the MediaWiki API
|
||||
1. Downloads the API's skill catalogue and indexes it by skill id.
|
||||
2. Enumerates every skill page via the MediaWiki API
|
||||
(`generator=categorymembers` over the five campaign categories
|
||||
`Core_skills`, `Prophecies_skills`, `Factions_skills`, `Nightfall_skills`,
|
||||
`Eye_of_the_North_skills`) with `prop=revisions&rvslots=main&rvprop=content`.
|
||||
2. Filters to pages containing a `{{Skill infobox}}` template.
|
||||
3. Parses each infobox via the tool-local `WikiSkillParser`.
|
||||
`Eye_of_the_North_skills`) with `prop=revisions&rvslots=main&rvprop=content`,
|
||||
filters to pages containing a `{{Skill infobox}}` template, and reads the
|
||||
page title plus the infobox's `id` field.
|
||||
3. Maps each id's API record onto Daybreak's model (`SkillMapper`).
|
||||
4. Resolves the canonical CDN URL for each skill icon by batched
|
||||
`prop=imageinfo` queries — preferring the high-resolution
|
||||
`<Name> (large).jpg`, falling back to `<Name>.jpg`, leaving the URL
|
||||
empty when neither file exists.
|
||||
5. Writes a sorted, grouped `Skill.g.cs`.
|
||||
|
||||
## Mapping notes
|
||||
|
||||
`SkillMapper` translates the client's encodings, which are not Daybreak's:
|
||||
|
||||
| Daybreak | API | Translation |
|
||||
| --- | --- | --- |
|
||||
| `Energy` | `energy_cost` | An encoded cost: `11` means 15, `12` means 25. |
|
||||
| `Adrenaline` | `adrenaline` | Stored in 25ths of a strike; rounded up. |
|
||||
| `Sacrifice` | `health_cost` | A whole percentage; Daybreak stores a fraction. |
|
||||
| `Upkeep` | `duration0` | Not a field — `131072` is the "maintained" sentinel, and those are exactly the `Upkeep = -1` skills. |
|
||||
| `Type` | `type` (+ `weapon_req`, `combo`, `profession`, `touch_range`, `activation`) | `GW::Constants::SkillType` is one value where Daybreak's `SkillType` is a flags enum, so sub-type flags (`Touch`, `Flash`, weapon, `Lead`/`OffHand`/`Dual`, `Binding`/`Nature`/`EbonVanguard`) are recovered from the accompanying fields. |
|
||||
|
||||
`campaign`, `profession` and `attribute` ids already match Daybreak's own, so
|
||||
they are looked up directly. Note that the API omits any key equal to its
|
||||
default, so a missing `energy_cost` means 0 — and a missing `attribute` means
|
||||
the skill has no governing attribute.
|
||||
|
||||
@@ -10,6 +10,11 @@ namespace Daybreak.Tools.SkillUpdater;
|
||||
/// <c>gcmcontinue</c>. Pages without a <c>{{Skill infobox}}</c> block (category
|
||||
/// indexes, "List of …" pages) are skipped. De-duplicates by page id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This yields the roster only — the page's name and the ids it covers. The
|
||||
/// values behind each id come from the GWToolbox API; see
|
||||
/// <see cref="WikiSkillEntry"/>.
|
||||
/// </remarks>
|
||||
internal sealed class SkillEnumerator(WikiHttpClient client)
|
||||
{
|
||||
private const string ApiBase = "https://wiki.guildwars.com/api.php";
|
||||
@@ -23,15 +28,15 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
|
||||
"Eye_of_the_North_skills",
|
||||
];
|
||||
|
||||
public async Task<IReadOnlyList<ParsedSkill>> EnumerateAsync(CancellationToken cancellationToken)
|
||||
public async Task<IReadOnlyList<WikiSkillEntry>> EnumerateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var seen = new Dictionary<int, ParsedSkill>();
|
||||
var seen = new Dictionary<int, WikiSkillEntry>();
|
||||
foreach (var category in CampaignCategories)
|
||||
{
|
||||
Console.WriteLine($"-> Category:{category}");
|
||||
var newCount = 0;
|
||||
var skipped = 0;
|
||||
await foreach (var (pageId, skill, isSkill) in this.EnumerateCategoryAsync(category, cancellationToken))
|
||||
await foreach (var (pageId, entry, isSkill) in this.EnumerateCategoryAsync(category, cancellationToken))
|
||||
{
|
||||
if (!isSkill)
|
||||
{
|
||||
@@ -39,7 +44,7 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.TryAdd(pageId, skill!))
|
||||
if (seen.TryAdd(pageId, entry!))
|
||||
{
|
||||
newCount++;
|
||||
}
|
||||
@@ -51,7 +56,7 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
|
||||
return [.. seen.Values];
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<(int PageId, ParsedSkill? Skill, bool IsSkill)> EnumerateCategoryAsync(
|
||||
private async IAsyncEnumerable<(int PageId, WikiSkillEntry? Entry, bool IsSkill)> EnumerateCategoryAsync(
|
||||
string category,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -78,10 +83,10 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
|
||||
{
|
||||
foreach (var page in pages.EnumerateArray())
|
||||
{
|
||||
if (TryProjectSkill(page, out var pageId, out var skill))
|
||||
if (TryProjectEntry(page, out var pageId, out var entry))
|
||||
{
|
||||
batchSkills++;
|
||||
yield return (pageId, skill, true);
|
||||
yield return (pageId, entry, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -96,10 +101,10 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
|
||||
while (gcmcontinue is not null);
|
||||
}
|
||||
|
||||
private static bool TryProjectSkill(JsonElement page, out int pageId, out ParsedSkill skill)
|
||||
private static bool TryProjectEntry(JsonElement page, out int pageId, out WikiSkillEntry entry)
|
||||
{
|
||||
pageId = 0;
|
||||
skill = null!;
|
||||
entry = null!;
|
||||
if (!page.TryGetProperty("title", out var titleEl) ||
|
||||
!page.TryGetProperty("pageid", out var pageIdEl))
|
||||
{
|
||||
@@ -131,33 +136,26 @@ internal sealed class SkillEnumerator(WikiHttpClient client)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!WikiSkillParser.TryParse(content, out var parsed))
|
||||
{
|
||||
Console.Error.WriteLine($" ! parse failed for '{title}' (pageid {pageIdEl.GetInt32()})");
|
||||
return false;
|
||||
}
|
||||
// Monster and environment skill pages routinely omit the id from their
|
||||
// infobox; keep them in the roster with no ids so the mapper can fall
|
||||
// back to resolving them by name against the API.
|
||||
WikiSkillRosterParser.TryParseIds(content, out var ids);
|
||||
|
||||
// The infobox `name` field can omit the `(PvP)` suffix even though the
|
||||
// page title carries it. Prefer the page title as the canonical name,
|
||||
// stripping the surrounding quotes some shout titles carry — and
|
||||
// remember the quoted form so the icon resolver can try it as well
|
||||
// (image files keep the quotes in their filenames).
|
||||
// The page title is the canonical name: the infobox `name` field can
|
||||
// omit the `(PvP)` suffix the title carries. Strip the surrounding
|
||||
// quotes some shout titles carry — and remember the quoted form so the
|
||||
// icon resolver can try it as well (image files keep the quotes in
|
||||
// their filenames).
|
||||
var rawTitle = title.Trim();
|
||||
var isQuoted = rawTitle.Length >= 2 && rawTitle[0] == '"' && rawTitle[^1] == '"';
|
||||
var canonicalTitle = isQuoted ? rawTitle[1..^1] : rawTitle;
|
||||
|
||||
if (!string.Equals(parsed.Name, canonicalTitle, StringComparison.Ordinal))
|
||||
{
|
||||
parsed = parsed with { Name = canonicalTitle };
|
||||
}
|
||||
|
||||
var iconBaseNames = isQuoted
|
||||
? (IReadOnlyList<string>)[canonicalTitle, rawTitle]
|
||||
: [canonicalTitle];
|
||||
parsed = parsed with { IconBaseNames = iconBaseNames };
|
||||
|
||||
pageId = pageIdEl.GetInt32();
|
||||
skill = parsed;
|
||||
entry = new WikiSkillEntry(canonicalTitle, ids)
|
||||
{
|
||||
IconBaseNames = isQuoted ? [canonicalTitle, rawTitle] : [canonicalTitle],
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ internal static class SkillFileWriter
|
||||
private const string Header = """
|
||||
// <auto-generated>
|
||||
// Generated by Tools/SkillUpdater. Do not edit manually — re-run
|
||||
// `dotnet run --project Tools/SkillUpdater` to refresh from the wiki.
|
||||
// `dotnet run --project Tools/SkillUpdater` to refresh from the
|
||||
// GWToolbox API.
|
||||
// </auto-generated>
|
||||
#nullable enable
|
||||
using Daybreak.Shared.Models.Guildwars;
|
||||
@@ -35,23 +36,9 @@ internal static class SkillFileWriter
|
||||
{
|
||||
// Expand skills carrying multiple ids (e.g. "Save Yourselves!" with
|
||||
// 1954 Luxon + 2097 Kurzick) into one emitted entry per id.
|
||||
var expanded = new List<ParsedSkill>();
|
||||
foreach (var skill in skills)
|
||||
{
|
||||
expanded.Add(skill);
|
||||
for (var i = 0; i < skill.AdditionalIds.Count; i++)
|
||||
{
|
||||
expanded.Add(skill with
|
||||
{
|
||||
Id = skill.AdditionalIds[i],
|
||||
AdditionalIds = [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stable ordering: by id ascending so identifier collisions get resolved
|
||||
// deterministically (lower id keeps the bare identifier).
|
||||
var ordered = expanded
|
||||
var ordered = skills
|
||||
.OrderBy(s => s.Id)
|
||||
.ThenBy(s => s.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// Turns one <see cref="GwToolboxSkill"/> into the literal C# tokens the writer
|
||||
/// emits. Every value Daybreak stores comes from here; the wiki only supplies
|
||||
/// the roster (which ids exist, under which display name) and the icon URL.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Where the client's data model differs from Daybreak's, the translation is
|
||||
/// derived from the client's own encoding rather than guessed:
|
||||
/// <list type="bullet">
|
||||
/// <item>energy is an encoded cost, not a literal one (11 → 15, 12 → 25);</item>
|
||||
/// <item>adrenaline is stored in 25ths of a strike;</item>
|
||||
/// <item>health cost is a whole percentage where Daybreak stores a fraction;</item>
|
||||
/// <item>upkeep is not a field at all — it is what the client's "maintained"
|
||||
/// duration sentinel means;</item>
|
||||
/// <item><c>GW::Constants::SkillType</c> is a single value, where Daybreak's
|
||||
/// <c>SkillType</c> is a flags enum, so the sub-type flags are recovered
|
||||
/// from the accompanying weapon/combo/profession fields.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static class SkillMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>duration0</c> of a maintained enchantment. The client has no upkeep
|
||||
/// field; a maintained enchantment is exactly the set of skills Daybreak
|
||||
/// records as <c>Upkeep = -1</c>.
|
||||
/// </summary>
|
||||
public const int MaintainedDuration = 131072;
|
||||
|
||||
/// <summary>Adrenaline units the client stores per strike.</summary>
|
||||
private const int AdrenalinePerStrike = 25;
|
||||
|
||||
/// <summary>
|
||||
/// The client's "this skill has no attribute" placeholder, which the API
|
||||
/// used to publish verbatim before it started omitting the key instead.
|
||||
/// </summary>
|
||||
private const int NoAttribute = 51;
|
||||
|
||||
public static ParsedSkill Map(int id, string name, GwToolboxSkill skill) =>
|
||||
new(
|
||||
Id: id,
|
||||
Name: name,
|
||||
CampaignIdentifier: ResolveCampaign(skill.Campaign),
|
||||
ProfessionIdentifier: ResolveProfession(skill.Profession),
|
||||
AttributeIdentifier: ResolveAttribute(skill.Attribute),
|
||||
PvEOnly: skill.PvEOnly != 0,
|
||||
PvP: skill.PvPOnly != 0,
|
||||
Elite: skill.Elite != 0,
|
||||
TypeExpression: ResolveSkillType(skill),
|
||||
Energy: NullIfZero(DecodeEnergyCost(skill.EnergyCost)),
|
||||
Activation: NullIfZero(skill.Activation),
|
||||
Recharge: NullIfZero(skill.Recharge),
|
||||
Overcast: NullIfZero(skill.Overcast),
|
||||
Adrenaline: NullIfZero(DecodeAdrenaline(skill.Adrenaline)),
|
||||
Sacrifice: NullIfZero(skill.HealthCost / 100.0),
|
||||
Upkeep: skill.Duration0 == MaintainedDuration ? -1 : null,
|
||||
Description: skill.Description ?? string.Empty,
|
||||
ConciseDescription: skill.Concise ?? string.Empty);
|
||||
|
||||
private static double? NullIfZero(double value) => value == 0 ? null : value;
|
||||
|
||||
/// <summary>
|
||||
/// The client stores energy as a code, not a cost: everything up to 10 is
|
||||
/// the cost itself, and the two costs above it get their own codes.
|
||||
/// </summary>
|
||||
private static double DecodeEnergyCost(int raw) => raw switch
|
||||
{
|
||||
11 => 15,
|
||||
12 => 25,
|
||||
_ => raw,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Adrenaline is stored in 25ths of a strike, but a skill costing a whole
|
||||
/// number of strikes can still hold a value that is not a clean multiple
|
||||
/// (an 80 that means four strikes), so the count rounds up.
|
||||
/// </summary>
|
||||
private static double DecodeAdrenaline(int raw) =>
|
||||
raw == 0 ? 0 : Math.Ceiling(raw / (double)AdrenalinePerStrike);
|
||||
|
||||
private static string ResolveCampaign(int campaign) =>
|
||||
campaign >= 0 && campaign < Campaigns.Length ? Campaigns[campaign] : "None";
|
||||
|
||||
private static string ResolveProfession(int profession) =>
|
||||
profession >= 0 && profession < Professions.Length ? Professions[profession] : "None";
|
||||
|
||||
private static string ResolveAttribute(int? attribute) =>
|
||||
attribute is not int id || id == NoAttribute
|
||||
? "None"
|
||||
: Attributes.GetValueOrDefault(id, "None");
|
||||
|
||||
/// <summary>
|
||||
/// Expands the client's single skill type into Daybreak's flag set, adding
|
||||
/// the sub-type flags the type alone does not carry.
|
||||
/// </summary>
|
||||
private static string ResolveSkillType(GwToolboxSkill skill)
|
||||
{
|
||||
var flags = new List<string>();
|
||||
void Add(string flag)
|
||||
{
|
||||
if (!flags.Contains(flag, StringComparer.Ordinal))
|
||||
{
|
||||
flags.Add(flag);
|
||||
}
|
||||
}
|
||||
|
||||
switch (skill.Type)
|
||||
{
|
||||
case GwSkillType.Stance: Add("Stance"); break;
|
||||
case GwSkillType.Hex: Add("Hex"); Add("Spell"); break;
|
||||
case GwSkillType.Spell: Add("Spell"); break;
|
||||
case GwSkillType.Enchantment:
|
||||
// A flash enchantment is one that takes no time to cast.
|
||||
if (skill.Activation == 0)
|
||||
{
|
||||
Add("Flash");
|
||||
}
|
||||
|
||||
Add("Enchantment");
|
||||
Add("Spell");
|
||||
break;
|
||||
case GwSkillType.Signet: Add("Signet"); break;
|
||||
case GwSkillType.Well: Add("Well"); Add("Spell"); break;
|
||||
case GwSkillType.Skill or GwSkillType.Skill2 or GwSkillType.Passive or GwSkillType.Environmental:
|
||||
Add("Skill");
|
||||
break;
|
||||
case GwSkillType.Ward: Add("Ward"); Add("Spell"); break;
|
||||
case GwSkillType.Glyph: Add("Glyph"); break;
|
||||
case GwSkillType.Attack:
|
||||
AddWeaponFlags(skill, Add);
|
||||
Add("Attack");
|
||||
break;
|
||||
case GwSkillType.Shout: Add("Shout"); break;
|
||||
case GwSkillType.Preparation: Add("Preparation"); break;
|
||||
case GwSkillType.PetAttack: Add("Pet"); Add("Attack"); break;
|
||||
case GwSkillType.Trap or GwSkillType.EnvironmentalTrap: Add("Trap"); break;
|
||||
case GwSkillType.Ritual:
|
||||
Add(ResolveRitualFlag(skill.Profession));
|
||||
Add("Ritual");
|
||||
break;
|
||||
case GwSkillType.ItemSpell: Add("Item"); Add("Spell"); break;
|
||||
case GwSkillType.WeaponSpell: Add("Weapon"); Add("Spell"); break;
|
||||
case GwSkillType.Form: Add("Form"); break;
|
||||
case GwSkillType.Chant: Add("Chant"); break;
|
||||
case GwSkillType.EchoRefrain: Add("Echo"); break;
|
||||
default:
|
||||
// Bounty, Scroll, Condition, Title and Disguise have no Daybreak
|
||||
// counterpart; they are effects rather than usable skills.
|
||||
break;
|
||||
}
|
||||
|
||||
if (skill.TouchRange != 0)
|
||||
{
|
||||
flags.Insert(0, "Touch");
|
||||
}
|
||||
|
||||
return flags.Count == 0
|
||||
? "SkillType.None"
|
||||
: string.Join(" | ", flags.Select(f => $"SkillType.{f}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A ritual's kind is not stored; the profession that owns it is what tells
|
||||
/// binding, nature and Ebon Vanguard rituals apart.
|
||||
/// </summary>
|
||||
private static string ResolveRitualFlag(int profession) => profession switch
|
||||
{
|
||||
GwProfession.Ritualist => "Binding",
|
||||
GwProfession.Ranger => "Nature",
|
||||
_ => "EbonVanguard",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Recovers the attack's weapon flags from the requirement bitmask. A mask
|
||||
/// naming exactly one weapon is that weapon; one naming several is a
|
||||
/// generic melee or ranged attack, which is how Daybreak models it.
|
||||
/// </summary>
|
||||
private static void AddWeaponFlags(GwToolboxSkill skill, Action<string> add)
|
||||
{
|
||||
var requirement = skill.WeaponRequirement;
|
||||
if (requirement == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Daggers have no flag of their own: a dagger attack is identified by
|
||||
// its place in the attack chain instead.
|
||||
if (requirement == GwWeapon.Daggers)
|
||||
{
|
||||
AddComboFlag(skill.Combo, add);
|
||||
return;
|
||||
}
|
||||
|
||||
if (SingleWeaponFlags.TryGetValue(requirement, out var weapon))
|
||||
{
|
||||
add(weapon);
|
||||
return;
|
||||
}
|
||||
|
||||
var meleeWeapons = System.Numerics.BitOperations.PopCount((uint)(requirement & GwWeapon.AnyMelee));
|
||||
var rangedWeapons = System.Numerics.BitOperations.PopCount((uint)(requirement & GwWeapon.AnyRanged));
|
||||
|
||||
// A mask spanning both melee and ranged weapons is "any weapon at all",
|
||||
// which is no restriction to record.
|
||||
if (meleeWeapons > 1 && rangedWeapons > 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (meleeWeapons > 1)
|
||||
{
|
||||
add("Melee");
|
||||
}
|
||||
else if (rangedWeapons > 1)
|
||||
{
|
||||
add("Ranged");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dagger attacks additionally carry their place in the attack chain.</summary>
|
||||
private static void AddComboFlag(int combo, Action<string> add)
|
||||
{
|
||||
switch (combo)
|
||||
{
|
||||
case 1: add("Lead"); break;
|
||||
case 2: add("OffHand"); break;
|
||||
case 3: add("Dual"); break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>GW::Constants::SkillType</c>.</summary>
|
||||
private static class GwSkillType
|
||||
{
|
||||
public const int Stance = 3;
|
||||
public const int Hex = 4;
|
||||
public const int Spell = 5;
|
||||
public const int Enchantment = 6;
|
||||
public const int Signet = 7;
|
||||
public const int Well = 9;
|
||||
public const int Skill = 10;
|
||||
public const int Ward = 11;
|
||||
public const int Glyph = 12;
|
||||
public const int Attack = 14;
|
||||
public const int Shout = 15;
|
||||
public const int Skill2 = 16;
|
||||
public const int Passive = 17;
|
||||
public const int Environmental = 18;
|
||||
public const int Preparation = 19;
|
||||
public const int PetAttack = 20;
|
||||
public const int Trap = 21;
|
||||
public const int Ritual = 22;
|
||||
public const int EnvironmentalTrap = 23;
|
||||
public const int ItemSpell = 24;
|
||||
public const int WeaponSpell = 25;
|
||||
public const int Form = 26;
|
||||
public const int Chant = 27;
|
||||
public const int EchoRefrain = 28;
|
||||
}
|
||||
|
||||
private static class GwProfession
|
||||
{
|
||||
public const int Ranger = 2;
|
||||
public const int Ritualist = 8;
|
||||
}
|
||||
|
||||
private static class GwWeapon
|
||||
{
|
||||
public const int Axe = 0x01;
|
||||
public const int Bow = 0x02;
|
||||
public const int Daggers = 0x08;
|
||||
public const int Hammer = 0x10;
|
||||
public const int Scythe = 0x20;
|
||||
public const int Spear = 0x40;
|
||||
public const int Sword = 0x80;
|
||||
|
||||
/// <summary>Bit 0x04 has no Daybreak counterpart but is a ranged weapon.</summary>
|
||||
public const int AnyRanged = Bow | 0x04 | Spear;
|
||||
public const int AnyMelee = Axe | Daggers | Hammer | Scythe | Sword;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<int, string> SingleWeaponFlags = new()
|
||||
{
|
||||
[GwWeapon.Axe] = "Axe",
|
||||
[GwWeapon.Bow] = "Bow",
|
||||
[GwWeapon.Hammer] = "Hammer",
|
||||
[GwWeapon.Scythe] = "Scythe",
|
||||
[GwWeapon.Spear] = "Spear",
|
||||
[GwWeapon.Sword] = "Sword",
|
||||
};
|
||||
|
||||
/// <summary>Indexed by the API's campaign id.</summary>
|
||||
private static readonly string[] Campaigns =
|
||||
[
|
||||
"Core",
|
||||
"Prophecies",
|
||||
"Factions",
|
||||
"Nightfall",
|
||||
"EyeOfTheNorth",
|
||||
"BonusMissionPack",
|
||||
];
|
||||
|
||||
/// <summary>Indexed by the API's profession id.</summary>
|
||||
private static readonly string[] Professions =
|
||||
[
|
||||
"None",
|
||||
"Warrior",
|
||||
"Ranger",
|
||||
"Monk",
|
||||
"Necromancer",
|
||||
"Mesmer",
|
||||
"Elementalist",
|
||||
"Assassin",
|
||||
"Ritualist",
|
||||
"Paragon",
|
||||
"Dervish",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Keyed by the API's attribute id, which is
|
||||
/// <c>GW::Constants::AttributeByte</c> — the same id Daybreak's
|
||||
/// <c>Attribute</c> already carries. Ids with no attribute are absent and
|
||||
/// resolve to <c>None</c>.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<int, string> Attributes = new()
|
||||
{
|
||||
[0] = "FastCasting",
|
||||
[1] = "IllusionMagic",
|
||||
[2] = "DominationMagic",
|
||||
[3] = "InspirationMagic",
|
||||
[4] = "BloodMagic",
|
||||
[5] = "DeathMagic",
|
||||
[6] = "SoulReaping",
|
||||
[7] = "Curses",
|
||||
[8] = "AirMagic",
|
||||
[9] = "EarthMagic",
|
||||
[10] = "FireMagic",
|
||||
[11] = "WaterMagic",
|
||||
[12] = "EnergyStorage",
|
||||
[13] = "HealingPrayers",
|
||||
[14] = "SmitingPrayers",
|
||||
[15] = "ProtectionPrayers",
|
||||
[16] = "DivineFavor",
|
||||
[17] = "Strength",
|
||||
[18] = "AxeMastery",
|
||||
[19] = "HammerMastery",
|
||||
[20] = "Swordsmanship",
|
||||
[21] = "Tactics",
|
||||
[22] = "BeastMastery",
|
||||
[23] = "Expertise",
|
||||
[24] = "WildernessSurvival",
|
||||
[25] = "Marksmanship",
|
||||
[29] = "DaggerMastery",
|
||||
[30] = "DeadlyArts",
|
||||
[31] = "ShadowArts",
|
||||
[32] = "Communing",
|
||||
[33] = "RestorationMagic",
|
||||
[34] = "ChannelingMagic",
|
||||
[35] = "CriticalStrikes",
|
||||
[36] = "SpawningPower",
|
||||
[37] = "SpearMastery",
|
||||
[38] = "Command",
|
||||
[39] = "Motivation",
|
||||
[40] = "Leadership",
|
||||
[41] = "ScytheMastery",
|
||||
[42] = "WindPrayers",
|
||||
[43] = "EarthPrayers",
|
||||
[44] = "Mysticism",
|
||||
};
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// A skill in the exact shape the writer will emit it: every value is already
|
||||
/// the literal C# token it should appear as in <c>Skill.g.cs</c>. This is
|
||||
/// deliberately tool-specific — the runtime <c>WikiService</c> has its own
|
||||
/// parser in <c>Daybreak.Shared</c> built around typed model objects.
|
||||
/// </summary>
|
||||
public sealed record ParsedSkill(
|
||||
int Id,
|
||||
string Name,
|
||||
string CampaignIdentifier,
|
||||
string ProfessionIdentifier,
|
||||
string AttributeIdentifier,
|
||||
bool PvEOnly,
|
||||
bool PvP,
|
||||
bool Elite,
|
||||
string TypeExpression,
|
||||
double? Energy,
|
||||
double? Activation,
|
||||
double? Recharge,
|
||||
double? Overcast,
|
||||
double? Adrenaline,
|
||||
double? Sacrifice,
|
||||
double? Upkeep,
|
||||
string Description,
|
||||
string ConciseDescription)
|
||||
{
|
||||
/// <summary>
|
||||
/// Extra ids the wiki listed alongside <see cref="Id"/> in the same
|
||||
/// <c>id =</c> field (e.g. <c>id = 1954, 2097</c> for Luxon/Kurzick
|
||||
/// "Save Yourselves!"). Each one becomes its own emitted skill.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> AdditionalIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Filenames (without the <c>File:</c> prefix) the icon resolver should
|
||||
/// try, in priority order. Defaults to just <see cref="Name"/>; shouts
|
||||
/// also include the quoted form because their image files preserve the
|
||||
/// surrounding quotes (e.g. <c>"Save Yourselves!".jpg</c>).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> IconBaseNames { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a wiki page's <c>{{Skill infobox …}}</c> into a <see cref="ParsedSkill"/>
|
||||
/// directly addressed at the codegen output. Every helper here exists because
|
||||
/// the codegen step needs it; nothing is exposed beyond <see cref="TryParse"/>.
|
||||
/// </summary>
|
||||
internal static partial class WikiSkillParser
|
||||
{
|
||||
public static bool TryParse(string? wikiText, out ParsedSkill skill)
|
||||
{
|
||||
skill = null!;
|
||||
if (string.IsNullOrWhiteSpace(wikiText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cleaned = CleanWikiText(wikiText);
|
||||
var body = ExtractInfoboxBody(cleaned);
|
||||
if (body is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fields = ExtractFields(body);
|
||||
|
||||
string Get(string key) => fields.TryGetValue(key, out var v) ? v : string.Empty;
|
||||
bool YesFlag(string key) => Get(key) is var v && v.Length > 0 && v[0] is 'y' or 'Y';
|
||||
|
||||
var allIds = ParseAllIds(Get("id"));
|
||||
|
||||
skill = new ParsedSkill(
|
||||
Id: allIds.Count > 0 ? allIds[0] : 0,
|
||||
Name: NormalizeName(Get("name")),
|
||||
CampaignIdentifier: ResolveCampaign(Get("campaign")),
|
||||
ProfessionIdentifier: ResolveProfession(Get("profession")),
|
||||
AttributeIdentifier: ResolveAttribute(Get("attribute")),
|
||||
PvEOnly: YesFlag("pve-only"),
|
||||
PvP: YesFlag("is-pvp"),
|
||||
Elite: YesFlag("elite"),
|
||||
TypeExpression: ResolveSkillType(Get("type")),
|
||||
Energy: ParseNumber(Get("energy")),
|
||||
Activation: ParseNumber(Get("activation")),
|
||||
Recharge: ParseNumber(Get("recharge")),
|
||||
Overcast: ParseNumber(Get("overcast")),
|
||||
Adrenaline: ParseNumber(Get("adrenaline")),
|
||||
Sacrifice: ParseNumber(Get("sacrifice")),
|
||||
Upkeep: ParseNumber(Get("upkeep")),
|
||||
Description: Get("description"),
|
||||
ConciseDescription: Get("concise description"));
|
||||
if (allIds.Count > 1)
|
||||
{
|
||||
skill = skill with { AdditionalIds = [.. allIds.Skip(1)] };
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips wiki markup the codegen output should not carry over (links,
|
||||
/// fraction templates, ranges, italics, HTML).
|
||||
/// </summary>
|
||||
private static string CleanWikiText(string wikiText)
|
||||
{
|
||||
var c = wikiText;
|
||||
c = HalfFractionRegex().Replace(c, "¹⁄₂");
|
||||
c = QuarterFractionRegex().Replace(c, "¹⁄₄");
|
||||
c = ThreeQuarterFractionRegex().Replace(c, "³⁄₄");
|
||||
c = ThreeOverTwoFractionRegex().Replace(c, "³⁄₂");
|
||||
c = DecimalRegex().Replace(c, "$1");
|
||||
c = GreenRangeRegex().Replace(c, "$1…$2");
|
||||
c = GreenRange2Regex().Replace(c, "$1…$2");
|
||||
c = GrayRangeRegex().Replace(c, "$1…$2");
|
||||
c = LinkWithDisplayTextRegex().Replace(c, "$2");
|
||||
c = SimpleLinkRegex().Replace(c, "$1");
|
||||
c = GrayTextRegex().Replace(c, "");
|
||||
c = GreyTextRegex().Replace(c, "");
|
||||
c = SicTextRegex().Replace(c, "");
|
||||
c = BoldTextRegex().Replace(c, "$1");
|
||||
c = ItalicTextRegex().Replace(c, "$1");
|
||||
c = HtmlTagRegex().Replace(c, "");
|
||||
return c;
|
||||
}
|
||||
|
||||
private static string? ExtractInfoboxBody(string wikiText)
|
||||
{
|
||||
const string startPattern = "{{Skill infobox";
|
||||
var startIndex = wikiText.IndexOf(startPattern, StringComparison.OrdinalIgnoreCase);
|
||||
if (startIndex == -1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pipeIndex = wikiText.IndexOf('|', startIndex);
|
||||
if (pipeIndex == -1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var braceCount = 2;
|
||||
var index = startIndex + startPattern.Length;
|
||||
while (index < wikiText.Length && braceCount > 0)
|
||||
{
|
||||
if (wikiText[index] == '{' && index + 1 < wikiText.Length && wikiText[index + 1] == '{') { braceCount += 2; index += 2; }
|
||||
else if (wikiText[index] == '}' && index + 1 < wikiText.Length && wikiText[index + 1] == '}') { braceCount -= 2; index += 2; }
|
||||
else { index++; }
|
||||
}
|
||||
|
||||
if (braceCount != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return wikiText.Substring(pipeIndex + 1, index - pipeIndex - 3).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the body manually so that values containing pipes inside nested
|
||||
/// templates (<c>{{gr|5|11}}</c>) or links don't get truncated.
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> ExtractFields(string body)
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var i = 0;
|
||||
while (i < body.Length)
|
||||
{
|
||||
while (i < body.Length && (body[i] == '|' || char.IsWhiteSpace(body[i])))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var nameStart = i;
|
||||
while (i < body.Length && body[i] != '=' && body[i] != '|')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i >= body.Length || body[i] != '=')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = body[nameStart..i].Trim();
|
||||
i++;
|
||||
|
||||
var valueStart = i;
|
||||
int braceDepth = 0, bracketDepth = 0;
|
||||
while (i < body.Length)
|
||||
{
|
||||
var c = body[i];
|
||||
if (c == '{' && i + 1 < body.Length && body[i + 1] == '{') { braceDepth++; i += 2; continue; }
|
||||
|
||||
if (c == '}' && i + 1 < body.Length && body[i + 1] == '}') { braceDepth--; i += 2; continue; }
|
||||
|
||||
if (c == '[' && i + 1 < body.Length && body[i + 1] == '[') { bracketDepth++; i += 2; continue; }
|
||||
|
||||
if (c == ']' && i + 1 < body.Length && body[i + 1] == ']') { bracketDepth--; i += 2; continue; }
|
||||
|
||||
if (c == '|' && braceDepth == 0 && bracketDepth == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
var value = WhitespaceRegex().Replace(body[valueStart..i].Trim(), " ").Trim();
|
||||
fields[name] = value;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Numeric infobox value → <c>double?</c>. Empty / non-numeric text
|
||||
/// (percentages, "morale boost", etc.) becomes <c>null</c>.
|
||||
/// </summary>
|
||||
private static double? ParseNumber(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var s = raw.Trim();
|
||||
|
||||
// Plain mixed/unit fractions glyph-substituted by CleanWikiText.
|
||||
var fraction = s.Contains("¹⁄₂") ? 0.5
|
||||
: s.Contains("¹⁄₄") ? 0.25
|
||||
: s.Contains("³⁄₄") ? 0.75
|
||||
: s.Contains("³⁄₂") ? 1.5
|
||||
: (double?)null;
|
||||
|
||||
if (fraction is double frac)
|
||||
{
|
||||
var fractionStart = s.IndexOfAny(['¹', '³']);
|
||||
if (fractionStart > 0 &&
|
||||
double.TryParse(s[..fractionStart].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var whole))
|
||||
{
|
||||
return whole + frac;
|
||||
}
|
||||
|
||||
return frac;
|
||||
}
|
||||
|
||||
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
|
||||
{
|
||||
return d;
|
||||
}
|
||||
|
||||
if (s.EndsWith('%') &&
|
||||
double.TryParse(s[..^1].TrimEnd('+'), NumberStyles.Float, CultureInfo.InvariantCulture, out var pct))
|
||||
{
|
||||
return pct / 100.0;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Some skill infoboxes carry multiple ids in one field
|
||||
/// (e.g. <c>id = 1954, 2097</c> for Luxon/Kurzick variants of "Save Yourselves!").
|
||||
/// We return every integer found in source order; the writer turns each
|
||||
/// one into its own emitted <c>Skill</c> entry.
|
||||
/// </summary>
|
||||
private static List<int> ParseAllIds(string raw)
|
||||
{
|
||||
var ids = new List<int>();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return ids;
|
||||
}
|
||||
|
||||
foreach (Match match in FirstIntegerRegex().Matches(raw))
|
||||
{
|
||||
if (int.TryParse(match.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
|
||||
{
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the surrounding double quotes some wiki page titles carry
|
||||
/// (shouts, e.g. <c>"Save Yourselves!"</c>) so the C# identifier and
|
||||
/// display name match the historical Daybreak convention.
|
||||
/// </summary>
|
||||
private static string NormalizeName(string raw)
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"')
|
||||
{
|
||||
return trimmed[1..^1];
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string ResolveSkillType(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return "SkillType.None";
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var parts = new List<string>();
|
||||
foreach (var token in TypeTokenSplitRegex().Split(raw))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TypeTokens.TryGetValue(token, out var canonical) && seen.Add(canonical))
|
||||
{
|
||||
parts.Add($"SkillType.{canonical}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? "SkillType.None" : string.Join(" | ", parts);
|
||||
}
|
||||
|
||||
private static string ResolveCampaign(string raw) =>
|
||||
Campaigns.GetValueOrDefault(raw.Trim(), "None");
|
||||
|
||||
private static string ResolveProfession(string raw) =>
|
||||
Professions.GetValueOrDefault(raw.Trim(), "None");
|
||||
|
||||
private static string ResolveAttribute(string raw) =>
|
||||
Attributes.GetValueOrDefault(raw.Trim(), "None");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The lookup tables intentionally live in this file, since they're a
|
||||
// codegen detail of the parser — the runtime types in Daybreak.Shared
|
||||
// already have their own canonical name lists.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static readonly Dictionary<string, string> Campaigns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[""] = "None",
|
||||
["None"] = "None",
|
||||
["Core"] = "Core",
|
||||
["Prophecies"] = "Prophecies",
|
||||
["Factions"] = "Factions",
|
||||
["Nightfall"] = "Nightfall",
|
||||
["Eye of the North"] = "EyeOfTheNorth",
|
||||
["EotN"] = "EyeOfTheNorth",
|
||||
["Bonus Mission Pack"] = "BonusMissionPack",
|
||||
["BMP"] = "BonusMissionPack",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> Professions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[""] = "None",
|
||||
["None"] = "None",
|
||||
["Common"] = "None",
|
||||
["Warrior"] = "Warrior",
|
||||
["Ranger"] = "Ranger",
|
||||
["Monk"] = "Monk",
|
||||
["Necromancer"] = "Necromancer",
|
||||
["Mesmer"] = "Mesmer",
|
||||
["Elementalist"] = "Elementalist",
|
||||
["Assassin"] = "Assassin",
|
||||
["Ritualist"] = "Ritualist",
|
||||
["Paragon"] = "Paragon",
|
||||
["Dervish"] = "Dervish",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> Attributes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[""] = "None",
|
||||
["None"] = "None",
|
||||
["Fast Casting"] = "FastCasting",
|
||||
["Illusion Magic"] = "IllusionMagic",
|
||||
["Domination Magic"] = "DominationMagic",
|
||||
["Inspiration Magic"] = "InspirationMagic",
|
||||
["Blood Magic"] = "BloodMagic",
|
||||
["Death Magic"] = "DeathMagic",
|
||||
["Soul Reaping"] = "SoulReaping",
|
||||
["Curses"] = "Curses",
|
||||
["Air Magic"] = "AirMagic",
|
||||
["Earth Magic"] = "EarthMagic",
|
||||
["Fire Magic"] = "FireMagic",
|
||||
["Water Magic"] = "WaterMagic",
|
||||
["Energy Storage"] = "EnergyStorage",
|
||||
["Healing Prayers"] = "HealingPrayers",
|
||||
["Smiting Prayers"] = "SmitingPrayers",
|
||||
["Protection Prayers"] = "ProtectionPrayers",
|
||||
["Divine Favor"] = "DivineFavor",
|
||||
["Strength"] = "Strength",
|
||||
["Axe Mastery"] = "AxeMastery",
|
||||
["Hammer Mastery"] = "HammerMastery",
|
||||
["Swordsmanship"] = "Swordsmanship",
|
||||
["Tactics"] = "Tactics",
|
||||
["Beast Mastery"] = "BeastMastery",
|
||||
["Expertise"] = "Expertise",
|
||||
["Wilderness Survival"] = "WildernessSurvival",
|
||||
["Marksmanship"] = "Marksmanship",
|
||||
["Critical Strikes"] = "CriticalStrikes",
|
||||
["Dagger Mastery"] = "DaggerMastery",
|
||||
["Deadly Arts"] = "DeadlyArts",
|
||||
["Shadow Arts"] = "ShadowArts",
|
||||
["Spawning Power"] = "SpawningPower",
|
||||
["Channeling Magic"] = "ChannelingMagic",
|
||||
["Communing"] = "Communing",
|
||||
["Restoration Magic"] = "RestorationMagic",
|
||||
["Spear Mastery"] = "SpearMastery",
|
||||
["Command"] = "Command",
|
||||
["Motivation"] = "Motivation",
|
||||
["Leadership"] = "Leadership",
|
||||
["Scythe Mastery"] = "ScytheMastery",
|
||||
["Wind Prayers"] = "WindPrayers",
|
||||
["Earth Prayers"] = "EarthPrayers",
|
||||
["Mysticism"] = "Mysticism",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> TypeTokens = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["skill"] = "Skill",
|
||||
["touch"] = "Touch",
|
||||
["spell"] = "Spell",
|
||||
["hex"] = "Hex",
|
||||
["enchantment"] = "Enchantment",
|
||||
["flash"] = "Flash",
|
||||
["echo"] = "Echo",
|
||||
["ward"] = "Ward",
|
||||
["glyph"] = "Glyph",
|
||||
["well"] = "Well",
|
||||
["ritual"] = "Ritual",
|
||||
["binding"] = "Binding",
|
||||
["nature"] = "Nature",
|
||||
["ebon"] = "EbonVanguard",
|
||||
["vanguard"] = "EbonVanguard",
|
||||
["item"] = "Item",
|
||||
["weapon"] = "Weapon",
|
||||
["attack"] = "Attack",
|
||||
["axe"] = "Axe",
|
||||
["bow"] = "Bow",
|
||||
["melee"] = "Melee",
|
||||
["hammer"] = "Hammer",
|
||||
["sword"] = "Sword",
|
||||
["spear"] = "Spear",
|
||||
["pet"] = "Pet",
|
||||
["lead"] = "Lead",
|
||||
["off-hand"] = "OffHand",
|
||||
["offhand"] = "OffHand",
|
||||
["dual"] = "Dual",
|
||||
["scythe"] = "Scythe",
|
||||
["ranged"] = "Ranged",
|
||||
["chant"] = "Chant",
|
||||
["shout"] = "Shout",
|
||||
["signet"] = "Signet",
|
||||
["preparation"] = "Preparation",
|
||||
["stance"] = "Stance",
|
||||
["form"] = "Form",
|
||||
["trap"] = "Trap",
|
||||
};
|
||||
|
||||
[GeneratedRegex(@"\{\{1/2\}\}", RegexOptions.IgnoreCase)] private static partial Regex HalfFractionRegex();
|
||||
[GeneratedRegex(@"\{\{1/4\}\}", RegexOptions.IgnoreCase)] private static partial Regex QuarterFractionRegex();
|
||||
[GeneratedRegex(@"\{\{3/4\}\}", RegexOptions.IgnoreCase)] private static partial Regex ThreeQuarterFractionRegex();
|
||||
[GeneratedRegex(@"\{\{3/2\}\}", RegexOptions.IgnoreCase)] private static partial Regex ThreeOverTwoFractionRegex();
|
||||
[GeneratedRegex(@"\{\{(\d+(?:\.\d+)?)\}\}")] private static partial Regex DecimalRegex();
|
||||
[GeneratedRegex(@"\{\{gr\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreenRangeRegex();
|
||||
[GeneratedRegex(@"\{\{gr2\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreenRange2Regex();
|
||||
[GeneratedRegex(@"\{\{gray\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)] private static partial Regex GrayRangeRegex();
|
||||
[GeneratedRegex(@"\{\{sic(?:\|([^}]*))?\}\}", RegexOptions.IgnoreCase)] private static partial Regex SicTextRegex();
|
||||
[GeneratedRegex(@"\[\[([^\]|]+)\]\]")] private static partial Regex SimpleLinkRegex();
|
||||
[GeneratedRegex(@"\[\[([^|\]]+)\|([^\]]+)\]\]")] private static partial Regex LinkWithDisplayTextRegex();
|
||||
[GeneratedRegex(@"'''([^']+)'''")] private static partial Regex BoldTextRegex();
|
||||
[GeneratedRegex(@"''([^']+)''")] private static partial Regex ItalicTextRegex();
|
||||
[GeneratedRegex(@"<[^>]*>")] private static partial Regex HtmlTagRegex();
|
||||
[GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex();
|
||||
[GeneratedRegex(@"\{\{gray\|([^}]+)\}\}", RegexOptions.IgnoreCase)] private static partial Regex GrayTextRegex();
|
||||
[GeneratedRegex(@"\{\{grey\|([^}]+)\}\}", RegexOptions.IgnoreCase)] private static partial Regex GreyTextRegex();
|
||||
[GeneratedRegex(@"[\s\-]+")] private static partial Regex TypeTokenSplitRegex();
|
||||
[GeneratedRegex(@"\d+")] private static partial Regex FirstIntegerRegex();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Daybreak.Tools.SkillUpdater;
|
||||
|
||||
/// <summary>
|
||||
/// One skill page on the wiki: the display name Daybreak knows the skill by,
|
||||
/// and the skill ids that name covers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The wiki is the roster only — it says which skills exist and what to call
|
||||
/// them. Every emitted value comes from the GWToolbox API instead
|
||||
/// (<see cref="SkillMapper"/>). The wiki is kept for the roster because the
|
||||
/// client's own names are not unique: four different skills are called "Charm
|
||||
/// Animal", where the wiki disambiguates them ("Charm Animal (White Mantle)"),
|
||||
/// and those unique names are what the generated C# identifiers and the icon
|
||||
/// lookup are keyed on.
|
||||
/// </remarks>
|
||||
public sealed record WikiSkillEntry(string Name, IReadOnlyList<int> Ids)
|
||||
{
|
||||
/// <summary>
|
||||
/// Filenames (without the <c>File:</c> prefix) the icon resolver should
|
||||
/// try, in priority order. Shouts include the quoted form because their
|
||||
/// image files preserve the surrounding quotes
|
||||
/// (e.g. <c>"Save Yourselves!".jpg</c>).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> IconBaseNames { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the roster out of a wiki page's <c>{{Skill infobox …}}</c>: the
|
||||
/// <c>id</c> field, and nothing else.
|
||||
/// </summary>
|
||||
internal static partial class WikiSkillRosterParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the skill ids out of a page's infobox. Some infoboxes carry
|
||||
/// several ids in the one field (e.g. <c>id = 1954, 2097</c> for the
|
||||
/// Luxon and Kurzick "Save Yourselves!"); each becomes its own emitted
|
||||
/// skill, with its own values from the API.
|
||||
/// </summary>
|
||||
public static bool TryParseIds(string? wikiText, out IReadOnlyList<int> ids)
|
||||
{
|
||||
ids = [];
|
||||
if (string.IsNullOrWhiteSpace(wikiText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var body = ExtractInfoboxBody(wikiText);
|
||||
if (body is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var match = IdFieldRegex().Match(body);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsed = new List<int>();
|
||||
foreach (Match integer in IntegerRegex().Matches(match.Groups[1].Value))
|
||||
{
|
||||
if (int.TryParse(integer.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
|
||||
{
|
||||
parsed.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
ids = parsed;
|
||||
return parsed.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the infobox's body by matching braces from <c>{{Skill infobox</c>
|
||||
/// so that nested templates don't end it early.
|
||||
/// </summary>
|
||||
private static string? ExtractInfoboxBody(string wikiText)
|
||||
{
|
||||
const string startPattern = "{{Skill infobox";
|
||||
var startIndex = wikiText.IndexOf(startPattern, StringComparison.OrdinalIgnoreCase);
|
||||
if (startIndex == -1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pipeIndex = wikiText.IndexOf('|', startIndex);
|
||||
if (pipeIndex == -1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var braceCount = 2;
|
||||
var index = startIndex + startPattern.Length;
|
||||
while (index < wikiText.Length && braceCount > 0)
|
||||
{
|
||||
if (wikiText[index] == '{' && index + 1 < wikiText.Length && wikiText[index + 1] == '{') { braceCount += 2; index += 2; }
|
||||
else if (wikiText[index] == '}' && index + 1 < wikiText.Length && wikiText[index + 1] == '}') { braceCount -= 2; index += 2; }
|
||||
else { index++; }
|
||||
}
|
||||
|
||||
if (braceCount != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return wikiText.Substring(pipeIndex + 1, index - pipeIndex - 3).Trim();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?:^|\|)\s*id\s*=\s*([^|\r\n]*)", RegexOptions.IgnoreCase)] private static partial Regex IdFieldRegex();
|
||||
[GeneratedRegex(@"\d+")] private static partial Regex IntegerRegex();
|
||||
}
|
||||
Reference in New Issue
Block a user