Docker Compose bot + Google Api
Build App / Build (push) Failing after 3s

This commit is contained in:
2026-08-06 16:23:50 +02:00
parent c9a3c252e1
commit 937b8554dd
44 changed files with 3360 additions and 74 deletions
@@ -0,0 +1,42 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Entity;
namespace LaDOSE.Business.Interface
{
public interface ISheetsExportService
{
/// <summary>What the export panel needs to render. Never includes a secret.</summary>
SheetsConfig GetConfig();
/// <summary>
/// Resolves the target spreadsheet from configuration, validates the tables, sanitises
/// and de-duplicates the tab titles, then hands off to the configured
/// <see cref="ISheetsWriter"/>. Throws <see cref="SheetsExportException"/> for anything
/// the caller can act on.
/// </summary>
Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default);
}
/// <summary>
/// A failure with a message meant for the person who pressed the button, plus the status
/// the API should answer with. The API has no exception middleware, so anything that escapes
/// reaches the browser as an HTML developer page the client can only report as a bare status
/// — hence every foreseeable failure is raised as one of these instead.
/// </summary>
public class SheetsExportException : Exception
{
public SheetsExportException(int statusCode, string message) : base(message)
{
StatusCode = statusCode;
}
public SheetsExportException(int statusCode, string message, Exception inner) : base(message, inner)
{
StatusCode = statusCode;
}
public int StatusCode { get; }
}
}
@@ -0,0 +1,26 @@
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Entity;
namespace LaDOSE.Business.Interface
{
/// <summary>
/// Writes tabular data into a Google Spreadsheet. Startup picks one implementation from
/// GoogleSheets:Writer, so nothing above this interface knows how Google is reached.
///
/// Implementations must be idempotent: writing the same request twice leaves the same
/// spreadsheet. They must also leave tabs they were not asked about completely alone —
/// the spreadsheet holds hand-made summaries and charts, and deletion is irreversible
/// through the API.
/// </summary>
public interface ISheetsWriter
{
/// <summary>Reported to the UI so the panel can say what it is talking to.</summary>
string Name { get; }
/// <summary>False when credentials are missing, so the API can answer 503 with a message.</summary>
bool IsConfigured { get; }
Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default);
}
}
@@ -12,5 +12,19 @@ namespace LaDOSE.Business.Interface
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
/// </summary>
Task<MatchStats> GetMatchStats(List<int> eventIds);
/// <summary>
/// The players a versus lookup can say something about: those with at least one
/// set in a bracket whose game is known, by display name.
/// </summary>
Task<List<PlayerOption>> GetVersusPlayers();
/// <summary>
/// Every recorded meeting between two players, all events, broken down per game.
/// Sets whose bracket has no game are excluded and only counted in
/// <see cref="PlayerVersus.UnknownGameSets"/>.
/// Missing, equal or unknown ids yield a well-formed empty <see cref="PlayerVersus"/>.
/// </summary>
Task<PlayerVersus> GetVersus(int playerAId, int playerBId);
}
}
@@ -8,10 +8,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.75.0.4178" />
<PackageReference Include="GraphQL.Client" Version="6.1.0" />
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="6.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
namespace LaDOSE.Business.Provider.SheetsProvider
{
/// <summary>
/// The default when GoogleSheets:Writer names nothing usable. Reports itself as
/// unconfigured so the API answers 503 with a message the user can act on, rather than
/// throwing a NullReferenceException that reaches the browser as an HTML developer page.
/// </summary>
public class DisabledSheetsWriter : ISheetsWriter
{
public string Name => "Disabled";
public bool IsConfigured => false;
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
{
throw new SheetsExportException(503,
"Google Sheets export is not configured on the server. Set GoogleSheets:Writer.");
}
}
}
@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Google;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
using Google.Apis.Sheets.v4;
using Google.Apis.Sheets.v4.Data;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
namespace LaDOSE.Business.Provider.SheetsProvider
{
/// <summary>
/// Writes to Google Sheets as a service account.
///
/// Two HTTP calls: read the existing tab list, then one batchUpdate. That second call is
/// atomic — either every tab lands or none does — so a failure can never leave the ranking
/// half updated.
///
/// Setup, once: create a service account, download its JSON key, point
/// GoogleSheets:ServiceAccount:CredentialsPath at it, and share the spreadsheet with the
/// account's ...iam.gserviceaccount.com address as Editor. That last step is the one people
/// forget, so a 403 says so explicitly and names the address.
/// </summary>
public class GoogleApiSheetsWriter : ISheetsWriter
{
private readonly string _credentialsPath;
public GoogleApiSheetsWriter(string credentialsPath)
{
_credentialsPath = credentialsPath;
}
public string Name => "ServiceAccount";
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(_credentialsPath) && File.Exists(_credentialsPath);
public async Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
{
if (!IsConfigured)
{
throw new SheetsExportException(503,
$"Service-account key not found at '{_credentialsPath}'. " +
"Check GoogleSheets:ServiceAccount:CredentialsPath and that the file is mounted.");
}
using var service = CreateService(out var accountEmail);
var tabs = request.Tabs ?? new List<SheetTable>();
try
{
// 1. What is already in the spreadsheet: titles, ids, and current grid sizes.
var get = service.Spreadsheets.Get(request.SpreadsheetId);
get.Fields = "spreadsheetId,spreadsheetUrl,sheets.properties(sheetId,title,index,gridProperties)";
var spreadsheet = await get.ExecuteAsync(ct);
var existing = (spreadsheet.Sheets ?? new List<Sheet>())
.Select(sheet => sheet.Properties)
.Where(properties => properties != null)
.ToList();
// 2. One atomic batch for the whole export.
var batch = BuildBatch(existing, tabs, out var results);
if (batch.Requests.Count > 0)
{
await service.Spreadsheets.BatchUpdate(batch, request.SpreadsheetId).ExecuteAsync(ct);
}
return new SheetExportResult
{
SpreadsheetId = spreadsheet.SpreadsheetId ?? request.SpreadsheetId,
SpreadsheetUrl = spreadsheet.SpreadsheetUrl ?? LoggingSheetsWriter.SheetUrl(request.SpreadsheetId),
Writer = Name,
Tabs = results,
Warnings = new List<string>()
};
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Forbidden)
{
throw new SheetsExportException(502,
$"Google refused access to spreadsheet '{request.SpreadsheetId}'. " +
$"Share it as Editor with {accountEmail ?? "the service account address"}.", ex);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new SheetsExportException(502,
$"No spreadsheet with id '{request.SpreadsheetId}'. " +
"Check GoogleSheets:SpreadsheetId — it is the id from the sheet URL, not the whole URL.", ex);
}
catch (GoogleApiException ex)
{
throw new SheetsExportException(502, $"Google rejected the write: {ex.Message}", ex);
}
}
/// <summary>
/// Builds the credential from the key file explicitly. GoogleCredential.FromFile and
/// .FromJson are both deprecated, and ServiceAccountCredential is already an HTTP client
/// initializer, so there is nothing for GoogleCredential to add here. Doing it this way
/// also hands us the account's own address for the "share the sheet with…" message.
/// </summary>
private SheetsService CreateService(out string accountEmail)
{
ServiceAccountCredential credential;
try
{
var json = File.ReadAllText(_credentialsPath);
var parameters = Google.Apis.Json.NewtonsoftJsonSerializer.Instance
.Deserialize<JsonCredentialParameters>(json);
if (parameters?.Type != JsonCredentialParameters.ServiceAccountCredentialType
|| string.IsNullOrEmpty(parameters.ClientEmail)
|| string.IsNullOrEmpty(parameters.PrivateKey))
{
throw new InvalidOperationException(
"not a service-account key (expected \"type\": \"service_account\" with client_email and private_key)");
}
accountEmail = parameters.ClientEmail;
credential = new ServiceAccountCredential(
new ServiceAccountCredential.Initializer(parameters.ClientEmail)
{
ProjectId = parameters.ProjectId,
KeyId = parameters.PrivateKeyId,
Scopes = new[] { SheetsService.Scope.Spreadsheets }
}.FromPrivateKey(parameters.PrivateKey));
}
catch (Exception ex)
{
throw new SheetsExportException(503,
$"Could not read the service-account key at '{_credentialsPath}': {ex.Message}", ex);
}
return new SheetsService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "LaDOSE"
});
}
#region Batch construction
/// <summary>
/// Turns "what the spreadsheet has" plus "what we want" into one ordered request list.
/// Deliberately static and free of I/O so the whole batch can be asserted in a test with
/// no network — the same split StatisticsService uses for its pure Aggregate.
///
/// Order matters: create missing tabs, resize, reindex, then per tab clear and write.
/// No tab is ever deleted — unlisted tabs hold hand-made summaries and charts.
/// </summary>
public static BatchUpdateSpreadsheetRequest BuildBatch(
List<SheetProperties> existing,
List<SheetTable> tabs,
out List<SheetTabResult> results)
{
var requests = new List<Request>();
results = new List<SheetTabResult>();
var byTitle = (existing ?? new List<SheetProperties>())
.Where(properties => properties.Title != null)
.GroupBy(properties => properties.Title, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
// Ids for tabs that do not exist yet are only known after the batch runs, so the
// clear and write for those address the sheet by title through a placeholder id.
// AddSheet with an explicit SheetId avoids that entirely: we pick the ids ourselves.
var nextId = NextFreeSheetId(byTitle.Values);
for (var index = 0; index < tabs.Count; index++)
{
var tab = tabs[index];
var values = ToRowData(tab);
var rowCount = values.Count;
var columnCount = tab.Header?.Count ?? 0;
byTitle.TryGetValue(tab.Name, out var properties);
var created = properties == null;
int sheetId;
if (created)
{
sheetId = nextId++;
requests.Add(new Request
{
AddSheet = new AddSheetRequest
{
Properties = new SheetProperties
{
SheetId = sheetId,
Title = tab.Name,
Index = index,
GridProperties = new GridProperties
{
RowCount = Math.Max(rowCount, 1),
ColumnCount = Math.Max(columnCount, 1),
FrozenRowCount = 1
}
}
}
});
}
else
{
sheetId = properties.SheetId ?? 0;
// Grow before writing: the API silently drops cells outside the grid, and a
// sheet created by hand defaults to 1000 x 26.
var haveRows = properties.GridProperties?.RowCount ?? 0;
var haveColumns = properties.GridProperties?.ColumnCount ?? 0;
if (haveRows < rowCount || haveColumns < columnCount)
{
requests.Add(new Request
{
UpdateSheetProperties = new UpdateSheetPropertiesRequest
{
Fields = "gridProperties.rowCount,gridProperties.columnCount",
Properties = new SheetProperties
{
SheetId = sheetId,
GridProperties = new GridProperties
{
RowCount = Math.Max(haveRows, rowCount),
ColumnCount = Math.Max(haveColumns, columnCount)
}
}
}
});
}
// Oldest event leftmost, so the tabs read in season order.
if (properties.Index != index)
{
requests.Add(new Request
{
UpdateSheetProperties = new UpdateSheetPropertiesRequest
{
Fields = "index",
Properties = new SheetProperties { SheetId = sheetId, Index = index }
}
});
}
}
// Clear the whole sheet first, then write. Fields = "userEnteredValue" is the
// equivalent of clearContents(): colours, notes and conditional formatting the
// user set up by hand all survive. Without the clear, cells beyond the new
// extent would linger from a previous, longer export.
requests.Add(new Request
{
UpdateCells = new UpdateCellsRequest
{
Range = new GridRange { SheetId = sheetId },
Fields = "userEnteredValue"
}
});
requests.Add(new Request
{
UpdateCells = new UpdateCellsRequest
{
Start = new GridCoordinate { SheetId = sheetId, RowIndex = 0, ColumnIndex = 0 },
Rows = values,
Fields = "userEnteredValue"
}
});
results.Add(new SheetTabResult
{
RequestedName = tab.Name,
Name = tab.Name,
Rows = rowCount,
Columns = columnCount,
Created = created
});
}
return new BatchUpdateSpreadsheetRequest { Requests = requests };
}
/// <summary>Sheet ids must be unique within the spreadsheet and are ours to choose.</summary>
private static int NextFreeSheetId(IEnumerable<SheetProperties> existing)
{
var used = existing.Select(properties => properties.SheetId ?? 0).DefaultIfEmpty(0).Max();
return Math.Max(used + 1, 1);
}
/// <summary>
/// Header, then one row per player, then the footer after a blank line. Points and totals
/// go in as numbers rather than text so the user's formulas keep working.
/// </summary>
private static List<RowData> ToRowData(SheetTable tab)
{
var header = tab.Header ?? new List<string>();
var width = header.Count;
var rows = new List<RowData>
{
new RowData { Values = header.Select(Text).ToList() }
};
foreach (var row in tab.Rows ?? new List<SheetRow>())
{
var cells = new List<CellData> { Text(row.Player) };
cells.AddRange((row.Points ?? new List<int>()).Select(Number));
cells.Add(Number(row.Total));
rows.Add(new RowData { Values = cells });
}
var footer = (tab.Footer ?? new List<string>()).Where(line => line != null).ToList();
if (footer.Count > 0)
{
rows.Add(new RowData { Values = Blank(width) });
foreach (var line in footer)
{
var cells = Blank(width);
if (cells.Count > 0) cells[0] = Text(line);
else cells.Add(Text(line));
rows.Add(new RowData { Values = cells });
}
}
return rows;
}
private static List<CellData> Blank(int width)
{
return Enumerable.Range(0, Math.Max(width, 0)).Select(_ => Text(string.Empty)).ToList();
}
private static CellData Text(string value)
{
return new CellData { UserEnteredValue = new ExtendedValue { StringValue = value ?? string.Empty } };
}
private static CellData Number(int value)
{
return new CellData { UserEnteredValue = new ExtendedValue { NumberValue = value } };
}
#endregion
}
}
@@ -0,0 +1,88 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
using Microsoft.Extensions.Logging;
namespace LaDOSE.Business.Provider.SheetsProvider
{
/// <summary>
/// Writes the export to the log instead of to Google, and reports success.
///
/// This exists so the whole feature — button, ordering, cumulative prefixes, payload shape —
/// can be exercised end to end before any Google account, key or sharing exists. Set
/// GoogleSheets:Writer to "Logging" and read the API log. It is also the safe way to try a
/// selection without touching the real spreadsheet.
/// </summary>
public class LoggingSheetsWriter : ISheetsWriter
{
private readonly ILogger<LoggingSheetsWriter> _logger;
public LoggingSheetsWriter(ILogger<LoggingSheetsWriter> logger)
{
_logger = logger;
}
public string Name => "Logging";
public bool IsConfigured => true;
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
{
var tabs = request.Tabs ?? new List<SheetTable>();
_logger.LogInformation(
"Sheets export (Logging writer): spreadsheet {SpreadsheetId}, {TabCount} tabs",
request.SpreadsheetId, tabs.Count);
foreach (var tab in tabs)
{
var header = tab.Header ?? new List<string>();
var rows = tab.Rows ?? new List<SheetRow>();
_logger.LogInformation(" [{Name}] event {EventId}: {RowCount} rows x {ColumnCount} columns | {Header}",
tab.Name, tab.EventId, rows.Count, header.Count, string.Join(" | ", header));
foreach (var row in rows)
{
_logger.LogInformation(" {Player} | {Points} | {Total}",
row.Player, string.Join(" | ", row.Points ?? new List<int>()), row.Total);
}
foreach (var line in tab.Footer ?? new List<string>())
{
_logger.LogInformation(" -- {Line}", line);
}
}
return Task.FromResult(new SheetExportResult
{
SpreadsheetId = request.SpreadsheetId,
SpreadsheetUrl = SheetUrl(request.SpreadsheetId),
Writer = Name,
Tabs = tabs.Select(tab => new SheetTabResult
{
RequestedName = tab.Name,
Name = tab.Name,
Rows = (tab.Rows?.Count ?? 0) + 1,
Columns = tab.Header?.Count ?? 0,
// Nothing was inspected, so this is a claim rather than an observation.
Created = true
}).ToList(),
Warnings = new List<string>
{
"Writer is 'Logging': nothing was written to Google. The payload is in the API log."
}
});
}
internal static string SheetUrl(string spreadsheetId)
{
return string.IsNullOrWhiteSpace(spreadsheetId)
? null
: $"https://docs.google.com/spreadsheets/d/{spreadsheetId}/edit";
}
}
}
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
namespace LaDOSE.Business.Service
{
/// <summary>
/// Everything about a spreadsheet export that is not Google-specific: resolving the target,
/// validating the tables, and making the tab titles legal and unique. Kept out of the writers
/// so the rules are stated once and can be tested without a network, the same way
/// StatisticsService keeps its aggregation in a pure static method.
/// </summary>
public class SheetsExportService : ISheetsExportService
{
private readonly SheetsSettings _settings;
private readonly ISheetsWriter _writer;
public SheetsExportService(SheetsSettings settings, ISheetsWriter writer)
{
_settings = settings ?? new SheetsSettings();
_writer = writer;
}
public SheetsConfig GetConfig()
{
return new SheetsConfig
{
Writer = _writer?.Name ?? "Disabled",
Configured = _writer != null
&& _writer.IsConfigured
&& !string.IsNullOrWhiteSpace(_settings.SpreadsheetId),
SpreadsheetId = _settings.SpreadsheetId ?? string.Empty,
MaxTabs = _settings.MaxTabs,
MaxRowsPerTab = _settings.MaxRowsPerTab
};
}
public async Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default)
{
if (_writer == null || !_writer.IsConfigured)
{
throw new SheetsExportException(503,
"Google Sheets export is not configured on the server.");
}
if (string.IsNullOrWhiteSpace(_settings.SpreadsheetId))
{
throw new SheetsExportException(400,
"No spreadsheet configured on the server. Set GoogleSheets:SpreadsheetId.");
}
var tabs = request?.Tabs?.Where(tab => tab != null).ToList() ?? new List<SheetTable>();
if (tabs.Count == 0)
{
throw new SheetsExportException(400, "No table to write.");
}
Validate(tabs);
var warnings = new List<string>();
NameTabs(tabs, warnings);
// The caller never names the spreadsheet; it is resolved here, from configuration.
var resolved = new SheetExportRequest
{
SpreadsheetId = _settings.SpreadsheetId.Trim(),
Tabs = tabs
};
var result = await _writer.WriteTablesAsync(resolved, ct);
result.Writer = _writer.Name;
result.Warnings = (result.Warnings ?? new List<string>()).Concat(warnings).ToList();
return result;
}
#region Validation
private void Validate(List<SheetTable> tabs)
{
if (tabs.Count > _settings.MaxTabs)
{
throw new SheetsExportException(400,
$"{tabs.Count} tabs requested, the limit is {_settings.MaxTabs}. Narrow the selection.");
}
foreach (var tab in tabs)
{
var header = tab.Header ?? new List<string>();
// "Players", at least one game, "Total".
if (header.Count < 3)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' has no game column — nothing was scored for it.");
}
if (header.Count > _settings.MaxColumns)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' has {header.Count} columns, the limit is {_settings.MaxColumns}.");
}
var rows = tab.Rows ?? new List<SheetRow>();
if (rows.Count > _settings.MaxRowsPerTab)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' has {rows.Count} rows, the limit is {_settings.MaxRowsPerTab}.");
}
var expected = header.Count - 2;
foreach (var row in rows)
{
var points = row?.Points?.Count ?? 0;
if (points != expected)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' row '{row?.Player}' has {points} point columns, " +
$"header declares {expected}.");
}
}
}
}
#endregion
#region Tab naming
/// <summary>
/// Google rejects these in a tab title. Replaced rather than stripped so "Ranking 13/14"
/// stays readable as "Ranking 13-14".
/// </summary>
private static readonly Regex Forbidden = new Regex(@"[:\\/?*\[\]]", RegexOptions.Compiled);
private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled);
private const int MaxTitleLength = 100;
/// <summary>
/// Makes every title legal and unique, in place, recording each change. Tab identity is
/// the title, so a rename means the next export writes somewhere else — which is exactly
/// why every rename is reported rather than applied quietly.
/// </summary>
private static void NameTabs(List<SheetTable> tabs, List<string> warnings)
{
var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var tab in tabs)
{
var requested = tab.Name ?? string.Empty;
var name = Sanitise(requested, tab.EventId);
if (!taken.Add(name))
{
var suffix = 2;
string candidate;
do
{
candidate = Truncate($"{name} ({suffix})");
suffix++;
} while (!taken.Add(candidate));
name = candidate;
}
if (!string.Equals(name, requested, StringComparison.Ordinal))
{
warnings.Add($"Tab renamed: '{requested}' -> '{name}'");
}
tab.Name = name;
}
}
/// <summary>
/// Trim, replace what Google forbids, collapse runs of whitespace, drop leading and
/// trailing apostrophes (Sheets uses them to quote a title), cap the length, and fall
/// back to the event id when nothing usable survives.
/// </summary>
public static string Sanitise(string requested, int eventId)
{
var name = (requested ?? string.Empty).Trim();
name = Forbidden.Replace(name, "-");
name = Whitespace.Replace(name, " ").Trim();
name = name.Trim('\'');
name = Truncate(name).Trim();
return string.IsNullOrWhiteSpace(name) ? $"Event {eventId}" : name;
}
private static string Truncate(string value)
{
return value.Length <= MaxTitleLength ? value : value.Substring(0, MaxTitleLength);
}
#endregion
}
}
@@ -19,9 +19,15 @@ namespace LaDOSE.Business.Service
/// - There is no winner column. The winner is inferred from the scores, and start.gg
/// encodes a DQ as -1, so games are clamped at 0.
///
/// - A Set has no game either. The game belongs to the <see cref="Tournament"/> the set
/// was played in, and Tournament.GameId is nullable, so any per-game breakdown has
/// to decide what to do with brackets that have none. <see cref="GetVersus"/> drops
/// them and reports how many it dropped.
///
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
/// issues one query per table and hands the loaded lists to the pure static
/// <see cref="Aggregate"/>, which is unit-testable without a database.
/// and <see cref="GetVersus"/> issue one query per table and hand the loaded lists to the pure
/// static <see cref="Aggregate"/> / <see cref="AggregateVersus"/>, which are unit-testable
/// without a database.
/// </summary>
public class StatisticsService : IStatisticsService
{
@@ -78,6 +84,108 @@ namespace LaDOSE.Business.Service
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
}
public Task<List<PlayerOption>> GetVersusPlayers()
{
// Same filter GetVersus applies, so the picker cannot offer a player whose
// every meeting would then be dropped as "game unknown". Self-sets are
// excluded here too, otherwise they would be counted in both slots.
var usable = from s in _context.Set
join t in _context.Tournament on s.TournamentId equals t.Id
where t.GameId != null && s.Player1Id != 0 && s.Player2Id != 0
&& s.Player1Id != s.Player2Id
select s;
// Counted in the database, one group-by per slot: the set table is the
// largest one here and there is no reason to pull it into memory.
var asPlayer1 = usable
.GroupBy(s => s.Player1Id)
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
.ToList();
var asPlayer2 = usable
.GroupBy(s => s.Player2Id)
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
.ToList();
var setsByPlayer = new Dictionary<int, int>();
foreach (var row in asPlayer1.Concat(asPlayer2))
{
setsByPlayer.TryGetValue(row.PlayerId, out var running);
setsByPlayer[row.PlayerId] = running + row.Sets;
}
if (setsByPlayer.Count == 0)
{
return Task.FromResult(new List<PlayerOption>());
}
var playerIds = setsByPlayer.Keys.ToList();
var nameById = _context.Player
.Where(p => playerIds.Contains(p.Id))
.ToList()
.ToDictionary(p => p.Id, DisplayName);
// A set can reference a player row that no longer exists; keep it as "#id"
// rather than hiding a real opponent from the picker.
var options = setsByPlayer
.Select(pair => new PlayerOption
{
Id = pair.Key,
Name = ResolveName(pair.Key, nameById),
Sets = pair.Value
})
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Id)
.ToList();
return Task.FromResult(options);
}
public Task<PlayerVersus> GetVersus(int playerAId, int playerBId)
{
// Nothing to look up, and nothing exceptional either: a caller that has not
// picked two distinct players gets an empty breakdown, not a 500.
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
{
return Task.FromResult(new PlayerVersus
{
PlayerAId = playerAId,
PlayerBId = playerBId
});
}
// Either seating: the set rows record whoever start.gg listed first.
var sets = _context.Set
.Where(s => (s.Player1Id == playerAId && s.Player2Id == playerBId)
|| (s.Player1Id == playerBId && s.Player2Id == playerAId))
.ToList();
var tournamentIds = sets.Select(s => s.TournamentId).Distinct().ToList();
var tournaments = tournamentIds.Count == 0
? new List<Tournament>()
: _context.Tournament
.Where(t => tournamentIds.Contains(t.Id))
.ToList();
var gameIds = tournaments
.Where(t => t.GameId.HasValue)
.Select(t => t.GameId.Value)
.Distinct()
.ToList();
var games = gameIds.Count == 0
? new List<Game>()
: _context.Game
.Where(g => gameIds.Contains(g.Id))
.ToList();
var players = _context.Player
.Where(p => p.Id == playerAId || p.Id == playerBId)
.ToList();
return Task.FromResult(AggregateVersus(playerAId, playerBId, sets, tournaments, games, players));
}
/// <summary>
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
/// </summary>
@@ -224,6 +332,142 @@ namespace LaDOSE.Business.Service
return result;
}
/// <summary>
/// Pure aggregation of the meetings between two players, per game. No database,
/// no I/O, deterministic.
///
/// Winners are inferred from the scores exactly as in <see cref="Aggregate"/>: equal
/// scores mean undecided. Undecided meetings still count in Sets — they happened —
/// but contribute to no win and no game count.
/// </summary>
/// <param name="playerAId">Left-hand player; WinsA is always their side.</param>
/// <param name="playerBId">Right-hand player.</param>
/// <param name="sets">Candidate sets; the pairing is re-checked here.</param>
/// <param name="tournaments">Tournaments of those sets, for Tournament.GameId.</param>
/// <param name="games">Games used to resolve names; may be incomplete.</param>
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
public static PlayerVersus AggregateVersus(
int playerAId,
int playerBId,
IEnumerable<Set> sets,
IEnumerable<Tournament> tournaments,
IEnumerable<Game> games,
IEnumerable<Player> players)
{
var result = new PlayerVersus
{
PlayerAId = playerAId,
PlayerBId = playerBId
};
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
{
return result;
}
var nameById = new Dictionary<int, string>();
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
{
nameById[player.Id] = DisplayName(player);
}
result.PlayerA = ResolveName(playerAId, nameById);
result.PlayerB = ResolveName(playerBId, nameById);
var gameIdByTournament = new Dictionary<int, int?>();
foreach (var tournament in (tournaments ?? Enumerable.Empty<Tournament>()).Where(t => t != null))
{
gameIdByTournament[tournament.Id] = tournament.GameId;
}
var gameById = new Dictionary<int, Game>();
foreach (var game in (games ?? Enumerable.Empty<Game>()).Where(g => g != null))
{
gameById[game.Id] = game;
}
var perGame = new Dictionary<int, VersusGameStats>();
foreach (var set in (sets ?? Enumerable.Empty<Set>()).Where(s => s != null))
{
// The query already restricts the pairing; checking again keeps this
// method correct on its own, which is the point of it being pure.
var aIsPlayer1 = set.Player1Id == playerAId && set.Player2Id == playerBId;
var bIsPlayer1 = set.Player1Id == playerBId && set.Player2Id == playerAId;
if (!aIsPlayer1 && !bIsPlayer1)
{
continue;
}
// No game on the bracket, nothing to file this meeting under. Counted so
// the caller can say "3 meetings we cannot attribute" instead of losing them.
if (!gameIdByTournament.TryGetValue(set.TournamentId, out var gameId) || !gameId.HasValue)
{
result.UnknownGameSets++;
continue;
}
var row = GetOrAddGame(perGame, gameId.Value, gameById);
row.Sets++;
var scoreA = aIsPlayer1 ? set.Player1Score : set.Player2Score;
var scoreB = aIsPlayer1 ? set.Player2Score : set.Player1Score;
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
if (scoreA == scoreB)
{
continue;
}
row.DecidedSets++;
// A DQ is stored as -1. Clamp so it never produces negative games.
row.GamesWonA += Math.Max(0, scoreA);
row.GamesWonB += Math.Max(0, scoreB);
if (scoreA > scoreB)
{
row.WinsA++;
}
else
{
row.WinsB++;
}
}
result.Games = perGame.Values
.OrderByDescending(g => g.Sets)
.ThenByDescending(g => g.DecidedSets)
.ThenBy(g => g.Game, StringComparer.Ordinal)
.ThenBy(g => g.GameId)
.ToList();
// Totals are derived from the rows, so the header cannot disagree with the table.
result.Sets = result.Games.Sum(g => g.Sets);
result.DecidedSets = result.Games.Sum(g => g.DecidedSets);
result.WinsA = result.Games.Sum(g => g.WinsA);
result.WinsB = result.Games.Sum(g => g.WinsB);
return result;
}
private static VersusGameStats GetOrAddGame(Dictionary<int, VersusGameStats> perGame, int gameId,
Dictionary<int, Game> gameById)
{
if (!perGame.TryGetValue(gameId, out var row))
{
gameById.TryGetValue(gameId, out var game);
var name = string.IsNullOrWhiteSpace(game?.Name) ? $"#{gameId}" : game.Name;
row = new VersusGameStats
{
GameId = gameId,
Game = name,
GameLongName = string.IsNullOrWhiteSpace(game?.LongName) ? name : game.LongName
};
perGame[gameId] = row;
}
return row;
}
private static PlayerMatchStats GetOrAdd(Dictionary<int, PlayerMatchStats> stats, int playerId,
Dictionary<int, string> nameById)
{