Compare commits

..

47 Commits

Author SHA1 Message Date
407e5e8ed5 Ajoute l Elo et les statistiques de parties 2026-04-16 00:17:52 +02:00
db233e7110 Documente les API utilisateurs externes 2026-04-15 23:47:01 +02:00
e0c3a41ccd Synchronise les parties entre les deux devices 2026-04-15 23:40:18 +02:00
3b88b9abe6 Empeche un joueur d etre des deux cotes 2026-04-15 23:13:00 +02:00
8ea6ef8424 Ajoute les amis et les invitations temps reel 2026-04-15 23:08:48 +02:00
9aae4cadc0 Condense la page utilisateur 2026-04-15 22:44:22 +02:00
b2cbab7891 Ajoute le preremplissage du joueur connecte 2026-04-15 22:40:12 +02:00
0db95ee6ec Memorise la connexion avec un cookie persistant 2026-04-15 22:35:27 +02:00
c92df9a8f0 Verrouille le nom d'utilisateur en edition admin 2026-04-15 22:28:37 +02:00
52d5bf4e98 Resserre davantage la modal d'edition admin 2026-04-15 22:21:52 +02:00
06405fd6a1 Condense la modal d'edition admin 2026-04-15 22:17:14 +02:00
cd576f941d Passe l'edition utilisateur admin dans une modal 2026-04-15 22:12:12 +02:00
99a3f6d0aa Ajoute des icones aux actions admin 2026-04-15 22:05:45 +02:00
0e6115e423 Ajoute une table de gestion des utilisateurs 2026-04-15 21:53:24 +02:00
655637072c Corrige la deconnexion du compte joueur 2026-04-15 21:42:40 +02:00
1d18a070e5 Ajoute une zone d'administration des utilisateurs 2026-04-15 21:21:26 +02:00
106786a638 Rapproche le menu mobile de la maquette 2026-04-15 19:56:29 +02:00
9fb7d2ce1b Rend le menu compte mobile plus compact 2026-04-15 19:40:12 +02:00
c23dcc8484 Masque le menu pendant l'authentification mobile 2026-04-14 23:03:02 +02:00
260b839f93 Evite de redeployer toute l'infrastructure a chaque update Proxmox 2026-04-14 22:57:34 +02:00
95575bef5f Corrige le masquage du menu au scroll 2026-04-14 22:40:40 +02:00
d7b743606a Ameliore le deploiement Proxmox derriere un reverse proxy 2026-04-14 20:49:22 +02:00
d36da7c993 Aligne le deploiement Proxmox sur la stack Docker complete 2026-04-14 20:32:39 +02:00
5cf46dce31 Ajoute une page utilisateur et une persistance MySQL 2026-04-14 20:03:26 +02:00
d0f9c76b26 Mieux gerer l'indisponibilite temporaire de l'auth 2026-04-14 19:42:56 +02:00
1c5a003f7f Masquer le menu pendant le defilement 2026-04-14 00:02:12 +02:00
9b739b02f6 Integrer l'authentification Keycloak dans l'application 2026-04-13 23:59:20 +02:00
53f0af761e Fixer la barre de menu en haut de page 2026-04-13 23:45:18 +02:00
e3d0a9faf2 Forcer le flux interactif dans la modal d'authentification 2026-04-13 23:38:05 +02:00
f2fdf00cb9 Rendre les actions de compte visibles sur l'accueil 2026-04-13 23:33:54 +02:00
f4bdbf9e2b Secours de stockage sans etat d'authentification 2026-04-13 23:30:05 +02:00
7efdbc57a8 Correction de l'acces a la page chrono 2026-04-13 23:26:00 +02:00
aac7977620 Correction du demarrage apres la modal d'authentification 2026-04-13 23:12:00 +02:00
740074c49e Ouverture de l'authentification dans une modal 2026-04-13 23:09:17 +02:00
7080fa8450 Ajout d'un menu general en haut du site 2026-04-13 23:04:21 +02:00
c8c3ba2253 Correction du flux d'inscription Keycloak 2026-04-13 22:58:24 +02:00
525f804e0b Rendre le site public et restaurer les acces compte 2026-04-13 22:52:08 +02:00
1d0a3f551f Deplacement des actions de compte sur l'accueil 2026-04-13 22:46:18 +02:00
6c3b9b77c6 Ajout des acces connexion et inscription sur l'accueil 2026-04-13 22:42:17 +02:00
6202b8b829 Mise en place de l'authentification Keycloak 2026-04-13 22:33:56 +02:00
70f693d85b Retire le focus automatique a la navigation 2026-04-13 21:48:37 +02:00
cdc8792972 Corrige le contour de focus au chargement 2026-04-13 21:44:01 +02:00
989b61e772 Ajoute l'application de Brice et sa mise a jour automatique 2026-04-13 21:42:04 +02:00
90f17c9c89 Migre le projet vers Blazor WebAssembly en .NET 10 2026-04-13 21:29:12 +02:00
b11056097d Evite le flash HTML avant le chargement du CSS 2026-04-13 20:56:45 +02:00
5c53b475b2 Fusionne la terminologie Block en mode Twice 2026-04-13 20:55:22 +02:00
0db53a42db Uniformise le terme Block en mode Twice 2026-04-13 20:52:38 +02:00
98 changed files with 19503 additions and 286 deletions

View File

@@ -1,3 +1,5 @@
.git
.codex
WhatsApp Video 2026-04-11 at 20.38.50.mp4
ChessCubing.App/bin
ChessCubing.App/obj

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
WEB_PORT=8080
PUBLIC_BASE_URL=http://localhost:8080
KEYCLOAK_DB_NAME=keycloak
KEYCLOAK_DB_USER=keycloak
KEYCLOAK_DB_PASSWORD=change-me
KEYCLOAK_ADMIN_USER=admin
KEYCLOAK_ADMIN_PASSWORD=change-me
SITE_DB_NAME=chesscubing_site
SITE_DB_USER=chesscubing
SITE_DB_PASSWORD=change-me
SITE_DB_ROOT_PASSWORD=change-me

5
.gitignore vendored
View File

@@ -1,2 +1,7 @@
.codex
.env
WhatsApp Video 2026-04-11 at 20.38.50.mp4
ChessCubing.App/bin/
ChessCubing.App/obj/
ChessCubing.Server/bin/
ChessCubing.Server/obj/

View File

@@ -0,0 +1,8 @@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
<RootNamespace>ChessCubing.App</RootNamespace>
<AssemblyName>ChessCubing.App</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<Content Include="..\favicon.png" Link="wwwroot/favicon.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\logo.png" Link="wwwroot/logo.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\transparent.png" Link="wwwroot/transparent.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\styles.css" Link="wwwroot/styles.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\site.webmanifest" Link="wwwroot/site.webmanifest" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\ChessCubing_Time_Reglement_Officiel_V1-1.pdf" Link="wwwroot/ChessCubing_Time_Reglement_Officiel_V1-1.pdf" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" Link="wwwroot/ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\ethan\**" Link="wwwroot/ethan/%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\brice\www\**" Link="wwwroot/brice/%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
@inject BrowserBridge Browser
@code {
[Parameter]
public string? Page { get; set; }
[Parameter]
public string? BodyClass { get; set; }
private string? _lastSignature;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await Browser.StartViewportAsync();
}
var signature = $"{Page ?? string.Empty}|{BodyClass ?? string.Empty}";
if (signature == _lastSignature)
{
return;
}
_lastSignature = signature;
await Browser.SetBodyStateAsync(Page, BodyClass);
}
}

View File

@@ -0,0 +1,61 @@
@implements IDisposable
@inject SocialRealtimeService Realtime
@inject NavigationManager Navigation
@if (Realtime.IncomingPlayInvite is not null)
{
<div class="play-overlay">
<div class="modal-backdrop"></div>
<section class="modal-card play-overlay-card" role="dialog" aria-modal="true" aria-labelledby="playInviteTitle">
<div class="modal-head">
<div>
<p class="eyebrow">Invitation de partie</p>
<h2 id="playInviteTitle">@Realtime.IncomingPlayInvite.SenderDisplayName veut jouer</h2>
</div>
</div>
<p class="play-overlay-copy">
@Realtime.IncomingPlayInvite.SenderDisplayName te propose une partie ChessCubing et te place cote @RecipientColorLabel.
</p>
<div class="play-overlay-actions">
<button class="button secondary small" type="button" @onclick="AcceptAsync">Accepter</button>
<button class="button ghost small" type="button" @onclick="DeclineAsync">Refuser</button>
</div>
</section>
</div>
}
@code {
private string RecipientColorLabel
=> string.Equals(Realtime.IncomingPlayInvite?.RecipientColor, "white", StringComparison.Ordinal)
? "blanc"
: "noir";
protected override async Task OnInitializedAsync()
{
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
}
private void HandleRealtimeChanged()
=> _ = InvokeAsync(StateHasChanged);
private async Task AcceptAsync()
{
await Realtime.RespondToIncomingPlayInviteAsync(accept: true);
var currentPath = new Uri(Navigation.Uri).AbsolutePath.Trim('/');
if (!string.Equals(currentPath, "application", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(currentPath, "application.html", StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo("/application.html");
}
}
private Task DeclineAsync()
=> Realtime.RespondToIncomingPlayInviteAsync(accept: false);
public void Dispose()
=> Realtime.Changed -= HandleRealtimeChanged;
}

View File

@@ -0,0 +1,13 @@
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
var returnUrl = string.IsNullOrWhiteSpace(relativePath)
? "/"
: relativePath.StartsWith("/", StringComparison.Ordinal) ? relativePath : $"/{relativePath}";
Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
}

View File

@@ -0,0 +1,568 @@
@using System.ComponentModel.DataAnnotations
@using System.Net
@using System.Net.Http.Json
@using System.Security.Claims
@using ChessCubing.App.Models.Auth
@using Microsoft.AspNetCore.Components.Authorization
@implements IDisposable
@inject AppAuthenticationStateProvider AuthenticationStateProvider
@inject BrowserBridge Browser
@inject HttpClient Http
@inject NavigationManager Navigation
<div class="site-menu-shell">
<header class="site-menu-bar">
<div class="site-menu-main @(IsMobileMenuOpen ? "is-mobile-open" : string.Empty)">
<a class="site-menu-brand" href="index.html" aria-label="Accueil ChessCubing">
<img class="site-menu-brand-icon" src="logo.png" alt="Icone ChessCubing" />
<span class="site-menu-brand-copy">
<span class="micro-label">ChessCubing Arena</span>
<strong>ChessCubing</strong>
</span>
</a>
<div class="site-menu-desktop">
<nav class="site-menu-links" aria-label="Navigation principale">
<a class="@BuildNavLinkClass(HomePaths)" href="index.html" aria-current="@BuildAriaCurrent(HomePaths)">Accueil</a>
<a class="@BuildNavLinkClass(ApplicationPaths)" href="application.html" aria-current="@BuildAriaCurrent(ApplicationPaths)">Application</a>
<a class="@BuildNavLinkClass(RulesPaths)" href="reglement.html" aria-current="@BuildAriaCurrent(RulesPaths)">Reglement</a>
<a class="@BuildNavLinkClass(UserPaths)" href="utilisateur.html" aria-current="@BuildAriaCurrent(UserPaths)">Utilisateur</a>
@if (IsAdmin)
{
<a class="@BuildNavLinkClass(AdminPaths)" href="administration.html" aria-current="@BuildAriaCurrent(AdminPaths)">Administration</a>
}
</nav>
<div class="site-menu-account">
<span class="micro-label">Compte joueur</span>
@if (IsAuthenticated)
{
<div class="site-menu-account-panel">
<div class="site-menu-user">
<strong>@DisplayName</strong>
<span>@DisplayMeta</span>
</div>
<a class="button ghost small" href="api/auth/logout/browser">Se deconnecter</a>
</div>
}
else
{
<div class="site-menu-account-actions">
<button class="button secondary small" type="button" @onclick="OpenLoginModal">Se connecter</button>
<button class="button ghost small" type="button" @onclick="OpenRegisterModal">Creer un compte</button>
</div>
}
</div>
</div>
<button class="site-menu-mobile-toggle"
type="button"
aria-label="@BuildMobileMenuToggleLabel()"
aria-controls="site-mobile-menu-panel"
aria-expanded="@BoolString(IsMobileMenuOpen)"
@onclick="ToggleMobileMenu">
<span class="site-menu-mobile-toggle-icon" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</span>
</button>
<div class="site-menu-mobile-panel" id="site-mobile-menu-panel">
<nav class="site-menu-mobile-links" aria-label="Navigation principale">
<a class="@BuildNavLinkClass(HomePaths)" href="index.html" aria-current="@BuildAriaCurrent(HomePaths)">Accueil</a>
<a class="@BuildNavLinkClass(ApplicationPaths)" href="application.html" aria-current="@BuildAriaCurrent(ApplicationPaths)">Application</a>
<a class="@BuildNavLinkClass(RulesPaths)" href="reglement.html" aria-current="@BuildAriaCurrent(RulesPaths)">Reglement</a>
<a class="@BuildNavLinkClass(UserPaths)" href="utilisateur.html" aria-current="@BuildAriaCurrent(UserPaths)">Utilisateur</a>
@if (IsAdmin)
{
<a class="@BuildNavLinkClass(AdminPaths)" href="administration.html" aria-current="@BuildAriaCurrent(AdminPaths)">Administration</a>
}
</nav>
<div class="site-menu-mobile-account">
<span class="micro-label">Compte joueur</span>
@if (IsAuthenticated)
{
<div class="site-menu-mobile-account-panel">
<div class="site-menu-mobile-user">
<strong>@DisplayName</strong>
<span>@DisplayMeta</span>
</div>
<a class="button ghost small" href="api/auth/logout/browser">Se deconnecter</a>
</div>
}
else
{
<div class="site-menu-mobile-account-actions">
<button class="button secondary small" type="button" @onclick="OpenLoginModal">Se connecter</button>
<button class="button ghost small" type="button" @onclick="OpenRegisterModal">Creer un compte</button>
</div>
}
</div>
</div>
</div>
</header>
</div>
<section class="modal @(ShowAuthModal ? string.Empty : "hidden")" aria-hidden="@BoolString(!ShowAuthModal)">
<div class="modal-backdrop" @onclick="CloseAuthModal"></div>
<div class="modal-card auth-modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Compte joueur</p>
<h2>@AuthModalTitle</h2>
</div>
<button class="button ghost small" type="button" @onclick="CloseAuthModal" disabled="@IsSubmitting">Fermer</button>
</div>
<p class="auth-modal-copy">
L'authentification se fait maintenant directement dans l'application, sans redirection vers une page externe.
</p>
<p class="auth-modal-copy">
La connexion reste memorisee sur ce navigateur pendant 30 jours.
</p>
<div class="auth-modal-switch">
<button class="@BuildModeButtonClass(AuthMode.Login)" type="button" @onclick="SwitchToLogin" disabled="@IsSubmitting">Se connecter</button>
<button class="@BuildModeButtonClass(AuthMode.Register)" type="button" @onclick="SwitchToRegister" disabled="@IsSubmitting">Creer un compte</button>
</div>
@if (!string.IsNullOrWhiteSpace(FormError))
{
<p class="auth-form-error">@FormError</p>
}
@if (Mode == AuthMode.Login)
{
<EditForm Model="@LoginModel" OnValidSubmit="SubmitLoginAsync">
<DataAnnotationsValidator />
<div class="auth-form-grid">
<label class="field">
<span>Identifiant ou email</span>
<InputText @bind-Value="LoginModel.Username" autocomplete="username email" />
<ValidationMessage For="@(() => LoginModel.Username)" />
</label>
<label class="field">
<span>Mot de passe</span>
<InputText @bind-Value="LoginModel.Password" type="password" autocomplete="current-password" />
<ValidationMessage For="@(() => LoginModel.Password)" />
</label>
</div>
<div class="modal-actions">
<button class="button secondary" type="submit" disabled="@IsSubmitting">
@(IsSubmitting ? "Connexion..." : "Se connecter")
</button>
</div>
</EditForm>
}
else
{
<EditForm Model="@RegisterModel" OnValidSubmit="SubmitRegisterAsync">
<DataAnnotationsValidator />
<div class="auth-form-grid two-columns">
<label class="field">
<span>Prenom</span>
<InputText @bind-Value="RegisterModel.FirstName" autocomplete="given-name" />
</label>
<label class="field">
<span>Nom</span>
<InputText @bind-Value="RegisterModel.LastName" autocomplete="family-name" />
</label>
<label class="field">
<span>Nom d'utilisateur</span>
<InputText @bind-Value="RegisterModel.Username" autocomplete="username" />
<ValidationMessage For="@(() => RegisterModel.Username)" />
</label>
<label class="field">
<span>Email</span>
<InputText @bind-Value="RegisterModel.Email" autocomplete="email" />
<ValidationMessage For="@(() => RegisterModel.Email)" />
</label>
<label class="field">
<span>Mot de passe</span>
<InputText @bind-Value="RegisterModel.Password" type="password" autocomplete="new-password" />
<ValidationMessage For="@(() => RegisterModel.Password)" />
</label>
<label class="field">
<span>Confirmation du mot de passe</span>
<InputText @bind-Value="RegisterModel.ConfirmPassword" type="password" autocomplete="new-password" />
<ValidationMessage For="@(() => RegisterModel.ConfirmPassword)" />
</label>
</div>
<div class="modal-actions">
<button class="button secondary" type="submit" disabled="@IsSubmitting">
@(IsSubmitting ? "Creation..." : "Creer mon compte")
</button>
</div>
</EditForm>
}
</div>
</section>
@code {
private static readonly string[] HomePaths = ["", "index.html"];
private static readonly string[] ApplicationPaths = ["application", "application.html"];
private static readonly string[] RulesPaths = ["reglement", "reglement.html"];
private static readonly string[] UserPaths = ["utilisateur", "utilisateur.html"];
private static readonly string[] AdminPaths = ["administration", "administration.html"];
private readonly LoginFormModel LoginModel = new();
private readonly RegisterFormModel RegisterModel = new();
private bool IsAuthenticated;
private bool IsAdmin;
private bool ShowAuthModal;
private bool IsSubmitting;
private bool IsMobileMenuOpen;
private string? FormError;
private string AuthModalTitle = "Se connecter";
private AuthMode Mode = AuthMode.Login;
private string DisplayName = "Utilisateur connecte";
private string DisplayMeta = "Session active";
private bool _syncMenuAfterRender;
private string CurrentPath
{
get
{
var absolutePath = new Uri(Navigation.Uri).AbsolutePath;
return absolutePath.Trim('/');
}
}
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
await RefreshAuthenticationStateAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_syncMenuAfterRender)
{
return;
}
_syncMenuAfterRender = false;
await Browser.SyncMenuAsync();
}
private string BuildNavLinkClass(string[] paths)
=> IsCurrentPage(paths) ? "site-menu-link is-active" : "site-menu-link";
private string? BuildAriaCurrent(string[] paths)
=> IsCurrentPage(paths) ? "page" : null;
private bool IsCurrentPage(string[] paths)
=> paths.Any(path => string.Equals(CurrentPath, path, StringComparison.OrdinalIgnoreCase));
private void OpenLoginModal()
{
CloseMobileMenu();
ShowAuthModal = true;
SwitchToLogin();
RequestMenuSync();
}
private void OpenRegisterModal()
{
CloseMobileMenu();
ShowAuthModal = true;
SwitchToRegister();
RequestMenuSync();
}
private void CloseAuthModal()
{
if (IsSubmitting)
{
return;
}
ShowAuthModal = false;
FormError = null;
RequestMenuSync();
}
private void SwitchToLogin()
{
Mode = AuthMode.Login;
AuthModalTitle = "Se connecter";
FormError = null;
}
private void SwitchToRegister()
{
Mode = AuthMode.Register;
AuthModalTitle = "Creer un compte";
FormError = null;
}
private string BuildModeButtonClass(AuthMode mode)
=> mode == Mode ? "button secondary small" : "button ghost small";
private async Task SubmitLoginAsync()
{
await SubmitAsync(
"api/auth/login",
new LoginRequest
{
Username = LoginModel.Username.Trim(),
Password = LoginModel.Password,
},
"Connexion impossible.");
}
private async Task SubmitRegisterAsync()
{
await SubmitAsync(
"api/auth/register",
new RegisterRequest
{
Username = RegisterModel.Username.Trim(),
Email = RegisterModel.Email.Trim(),
Password = RegisterModel.Password,
ConfirmPassword = RegisterModel.ConfirmPassword,
FirstName = RegisterModel.FirstName?.Trim(),
LastName = RegisterModel.LastName?.Trim(),
},
"Creation du compte impossible.");
}
private async Task SubmitAsync<TRequest>(string endpoint, TRequest payload, string fallbackMessage)
{
if (IsSubmitting)
{
return;
}
IsSubmitting = true;
FormError = null;
try
{
if (!await EnsureAuthServiceReadyAsync())
{
FormError = "Le service d'authentification demarre encore. Reessaie dans quelques secondes.";
return;
}
var response = await Http.PostAsJsonAsync(endpoint, payload);
if (!response.IsSuccessStatusCode)
{
FormError = await ReadErrorAsync(response, fallbackMessage);
return;
}
var session = await response.Content.ReadFromJsonAsync<AuthSessionResponse>();
if (session is not null && session.IsAuthenticated)
{
AuthenticationStateProvider.SetAuthenticated(session);
}
else
{
await AuthenticationStateProvider.RefreshAsync();
}
ShowAuthModal = false;
FormError = null;
ResetForms();
CloseMobileMenu();
await RefreshAuthenticationStateAsync();
RequestMenuSync();
}
catch (HttpRequestException)
{
FormError = "Le service d'authentification est temporairement indisponible. Reessaie dans quelques secondes.";
}
catch (TaskCanceledException)
{
FormError = "La reponse du service d'authentification a pris trop de temps. Reessaie dans quelques secondes.";
}
catch
{
FormError = fallbackMessage;
}
finally
{
IsSubmitting = false;
}
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
=> _ = InvokeAsync(RefreshAuthenticationStateAsync);
private void ToggleMobileMenu()
{
IsMobileMenuOpen = !IsMobileMenuOpen;
RequestMenuSync();
}
private void CloseMobileMenu()
{
if (!IsMobileMenuOpen)
{
return;
}
IsMobileMenuOpen = false;
RequestMenuSync();
}
private string BuildMobileMenuToggleLabel()
=> IsMobileMenuOpen ? "Fermer le menu mobile" : "Ouvrir le menu mobile";
private void RequestMenuSync()
=> _syncMenuAfterRender = true;
private async Task RefreshAuthenticationStateAsync()
{
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity?.IsAuthenticated == true)
{
IsAuthenticated = true;
IsAdmin = user.IsInRole("admin");
DisplayName = BuildDisplayName(user);
DisplayMeta = BuildMeta(user);
}
else
{
ResetAuthenticationDisplay();
}
}
catch
{
ResetAuthenticationDisplay();
}
StateHasChanged();
}
private void ResetAuthenticationDisplay()
{
IsAuthenticated = false;
IsAdmin = false;
DisplayName = "Utilisateur connecte";
DisplayMeta = "Session active";
}
private void ResetForms()
{
LoginModel.Username = string.Empty;
LoginModel.Password = string.Empty;
RegisterModel.FirstName = string.Empty;
RegisterModel.LastName = string.Empty;
RegisterModel.Username = string.Empty;
RegisterModel.Email = string.Empty;
RegisterModel.Password = string.Empty;
RegisterModel.ConfirmPassword = string.Empty;
}
private static string BuildDisplayName(ClaimsPrincipal user)
=> user.FindFirst("name")?.Value
?? user.FindFirst("preferred_username")?.Value
?? user.Identity?.Name
?? "Utilisateur connecte";
private static string BuildMeta(ClaimsPrincipal user)
=> user.FindFirst("email")?.Value
?? "Session active";
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
{
try
{
var error = await response.Content.ReadFromJsonAsync<ApiErrorMessage>();
if (!string.IsNullOrWhiteSpace(error?.Message))
{
return error.Message;
}
}
catch
{
}
return response.StatusCode switch
{
HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
=> "Le service d'authentification demarre encore. Reessaie dans quelques secondes.",
HttpStatusCode.Conflict
=> "Ce nom d'utilisateur ou cet email existe deja.",
_ => fallbackMessage,
};
}
private async Task<bool> EnsureAuthServiceReadyAsync()
{
try
{
using var response = await Http.GetAsync("api/health");
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
private static string BoolString(bool value)
=> value ? "true" : "false";
public void Dispose()
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
private enum AuthMode
{
Login,
Register,
}
private sealed class ApiErrorMessage
{
public string? Message { get; set; }
}
private sealed class LoginFormModel
{
[Required(ErrorMessage = "L'identifiant est obligatoire.")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "Le mot de passe est obligatoire.")]
public string Password { get; set; } = string.Empty;
}
private sealed class RegisterFormModel
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
[Required(ErrorMessage = "Le nom d'utilisateur est obligatoire.")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "L'email est obligatoire.")]
[EmailAddress(ErrorMessage = "L'email n'est pas valide.")]
public string Email { get; set; } = string.Empty;
[Required(ErrorMessage = "Le mot de passe est obligatoire.")]
[MinLength(8, ErrorMessage = "Le mot de passe doit contenir au moins 8 caracteres.")]
public string Password { get; set; } = string.Empty;
[Required(ErrorMessage = "La confirmation est obligatoire.")]
[Compare(nameof(Password), ErrorMessage = "Les mots de passe ne correspondent pas.")]
public string ConfirmPassword { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,33 @@
@inherits LayoutComponentBase
@inject NavigationManager Navigation
@if (!HideGlobalMenu)
{
<SiteMenu />
<main class="site-layout-body">
@Body
</main>
}
else
{
@Body
}
<PlayInviteOverlay />
@code {
private bool HideGlobalMenu
{
get
{
var currentPath = new Uri(Navigation.Uri).AbsolutePath.Trim('/');
return string.Equals(currentPath, "chrono", StringComparison.OrdinalIgnoreCase)
|| string.Equals(currentPath, "chrono.html", StringComparison.OrdinalIgnoreCase)
|| string.Equals(currentPath, "cube", StringComparison.OrdinalIgnoreCase)
|| string.Equals(currentPath, "cube.html", StringComparison.OrdinalIgnoreCase)
|| string.Equals(currentPath, "application", StringComparison.OrdinalIgnoreCase)
|| string.Equals(currentPath, "application.html", StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@@ -0,0 +1,16 @@
namespace ChessCubing.App.Models.Auth;
public sealed class AuthSessionResponse
{
public bool IsAuthenticated { get; set; }
public string? Subject { get; set; }
public string? Username { get; set; }
public string? Name { get; set; }
public string? Email { get; set; }
public string[] Roles { get; set; } = [];
}

View File

@@ -0,0 +1,8 @@
namespace ChessCubing.App.Models.Auth;
public sealed class LoginRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,16 @@
namespace ChessCubing.App.Models.Auth;
public sealed class RegisterRequest
{
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string ConfirmPassword { get; set; } = string.Empty;
public string? FirstName { get; set; }
public string? LastName { get; set; }
}

View File

@@ -0,0 +1,300 @@
using System.Text.Json.Serialization;
using ChessCubing.App.Services;
namespace ChessCubing.App.Models;
public sealed class MatchConfig
{
[JsonPropertyName("matchLabel")]
public string MatchLabel { get; set; } = "Rencontre ChessCubing";
[JsonPropertyName("competitionMode")]
public bool CompetitionMode { get; set; }
[JsonPropertyName("mode")]
public string Mode { get; set; } = MatchEngine.ModeTwice;
[JsonPropertyName("preset")]
public string Preset { get; set; } = MatchEngine.PresetFast;
[JsonPropertyName("blockDurationMs")]
public long BlockDurationMs { get; set; } = MatchEngine.DefaultBlockDurationMs;
[JsonPropertyName("moveLimitMs")]
public long MoveLimitMs { get; set; } = MatchEngine.DefaultMoveLimitMs;
[JsonPropertyName("timeInitialMs")]
public long TimeInitialMs { get; set; } = MatchEngine.TimeModeInitialClockMs;
[JsonPropertyName("whiteName")]
public string WhiteName { get; set; } = "Blanc";
[JsonPropertyName("blackName")]
public string BlackName { get; set; } = "Noir";
[JsonPropertyName("arbiterName")]
public string ArbiterName { get; set; } = string.Empty;
[JsonPropertyName("eventName")]
public string EventName { get; set; } = string.Empty;
[JsonPropertyName("notes")]
public string Notes { get; set; } = string.Empty;
}
public sealed class MatchState
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; set; } = 4;
[JsonPropertyName("matchId")]
public string MatchId { get; set; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("collaborationSessionId")]
public string? CollaborationSessionId { get; set; }
[JsonPropertyName("whiteSubject")]
public string? WhiteSubject { get; set; }
[JsonPropertyName("blackSubject")]
public string? BlackSubject { get; set; }
[JsonPropertyName("config")]
public MatchConfig Config { get; set; } = new();
[JsonPropertyName("phase")]
public string Phase { get; set; } = MatchEngine.PhaseBlock;
[JsonPropertyName("running")]
public bool Running { get; set; }
[JsonPropertyName("lastTickAt")]
public long? LastTickAt { get; set; }
[JsonPropertyName("blockNumber")]
public int BlockNumber { get; set; } = 1;
[JsonPropertyName("currentTurn")]
public string CurrentTurn { get; set; } = MatchEngine.ColorWhite;
[JsonPropertyName("blockRemainingMs")]
public long BlockRemainingMs { get; set; } = MatchEngine.DefaultBlockDurationMs;
[JsonPropertyName("moveRemainingMs")]
public long MoveRemainingMs { get; set; } = MatchEngine.DefaultMoveLimitMs;
[JsonPropertyName("quota")]
public int Quota { get; set; } = 6;
[JsonPropertyName("moves")]
public PlayerIntPair Moves { get; set; } = new();
[JsonPropertyName("clocks")]
public PlayerLongPair? Clocks { get; set; }
[JsonPropertyName("lastMover")]
public string? LastMover { get; set; }
[JsonPropertyName("awaitingBlockClosure")]
public bool AwaitingBlockClosure { get; set; }
[JsonPropertyName("closureReason")]
public string ClosureReason { get; set; } = string.Empty;
[JsonPropertyName("result")]
public string? Result { get; set; }
[JsonPropertyName("resultRecordedUtc")]
public DateTime? ResultRecordedUtc { get; set; }
[JsonPropertyName("cube")]
public CubeState Cube { get; set; } = new();
[JsonPropertyName("doubleCoup")]
public DoubleCoupState DoubleCoup { get; set; } = new();
[JsonPropertyName("history")]
public List<MatchHistoryEntry> History { get; set; } = [];
}
public sealed class PlayerIntPair
{
[JsonPropertyName("white")]
public int White { get; set; }
[JsonPropertyName("black")]
public int Black { get; set; }
}
public sealed class PlayerLongPair
{
[JsonPropertyName("white")]
public long White { get; set; }
[JsonPropertyName("black")]
public long Black { get; set; }
}
public sealed class PlayerNullableLongPair
{
[JsonPropertyName("white")]
public long? White { get; set; }
[JsonPropertyName("black")]
public long? Black { get; set; }
}
public sealed class CubeState
{
[JsonPropertyName("number")]
public int? Number { get; set; }
[JsonPropertyName("running")]
public bool Running { get; set; }
[JsonPropertyName("startedAt")]
public long? StartedAt { get; set; }
[JsonPropertyName("elapsedMs")]
public long ElapsedMs { get; set; }
[JsonPropertyName("phaseAlertPending")]
public bool PhaseAlertPending { get; set; }
[JsonPropertyName("times")]
public PlayerNullableLongPair Times { get; set; } = new();
[JsonPropertyName("playerState")]
public CubePlayerStates PlayerState { get; set; } = new();
[JsonPropertyName("round")]
public int Round { get; set; } = 1;
[JsonPropertyName("history")]
public List<CubeHistoryEntry> History { get; set; } = [];
}
public sealed class CubePlayerStates
{
[JsonPropertyName("white")]
public CubePlayerState White { get; set; } = new();
[JsonPropertyName("black")]
public CubePlayerState Black { get; set; } = new();
}
public sealed class CubePlayerState
{
[JsonPropertyName("running")]
public bool Running { get; set; }
[JsonPropertyName("startedAt")]
public long? StartedAt { get; set; }
[JsonPropertyName("elapsedMs")]
public long ElapsedMs { get; set; }
}
public sealed class DoubleCoupState
{
[JsonPropertyName("eligible")]
public bool Eligible { get; set; }
[JsonPropertyName("step")]
public int Step { get; set; }
[JsonPropertyName("starter")]
public string Starter { get; set; } = MatchEngine.ColorWhite;
}
public sealed class MatchHistoryEntry
{
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("time")]
public string Time { get; set; } = string.Empty;
}
public sealed class CubeHistoryEntry
{
[JsonPropertyName("blockNumber")]
public int BlockNumber { get; set; }
[JsonPropertyName("number")]
public int? Number { get; set; }
[JsonPropertyName("white")]
public long? White { get; set; }
[JsonPropertyName("black")]
public long? Black { get; set; }
}
public sealed class SetupFormModel
{
public bool CompetitionMode { get; set; }
public string MatchLabel { get; set; } = string.Empty;
public string Mode { get; set; } = MatchEngine.ModeTwice;
public string Preset { get; set; } = MatchEngine.PresetFast;
public int BlockMinutes { get; set; } = 3;
public int MoveSeconds { get; set; } = 20;
public int TimeInitialMinutes { get; set; } = 10;
public string WhiteName { get; set; } = "Blanc";
public string BlackName { get; set; } = "Noir";
public string ArbiterName { get; set; } = string.Empty;
public string EventName { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public MatchConfig ToMatchConfig()
{
return new MatchConfig
{
MatchLabel = MatchEngine.SanitizeText(MatchLabel) is { Length: > 0 } label ? label : "Rencontre ChessCubing",
CompetitionMode = CompetitionMode,
Mode = Mode,
Preset = Preset,
BlockDurationMs = MatchEngine.NormalizeDurationMs(BlockMinutes * 60_000L, MatchEngine.DefaultBlockDurationMs),
MoveLimitMs = MatchEngine.NormalizeDurationMs(MoveSeconds * 1_000L, MatchEngine.DefaultMoveLimitMs),
TimeInitialMs = MatchEngine.NormalizeDurationMs(TimeInitialMinutes * 60_000L, MatchEngine.TimeModeInitialClockMs),
WhiteName = MatchEngine.SanitizeText(WhiteName) is { Length: > 0 } white ? white : "Blanc",
BlackName = MatchEngine.SanitizeText(BlackName) is { Length: > 0 } black ? black : "Noir",
ArbiterName = MatchEngine.SanitizeText(ArbiterName),
EventName = MatchEngine.SanitizeText(EventName),
Notes = MatchEngine.SanitizeText(Notes),
};
}
public static SetupFormModel CreateDemo()
{
return new SetupFormModel
{
CompetitionMode = true,
MatchLabel = "Demo officielle ChessCubing",
Mode = MatchEngine.ModeTwice,
Preset = MatchEngine.PresetFreeze,
BlockMinutes = 3,
MoveSeconds = 20,
TimeInitialMinutes = 10,
WhiteName = "Nora",
BlackName = "Leo",
ArbiterName = "Arbitre demo",
EventName = "Session telephone",
Notes = "8 cubes verifies, variante prete, tirage au sort effectue.",
};
}
}
public sealed record MatchPresetInfo(string Label, int Quota, string Description);
public sealed record MatchModeInfo(string Label, string Subtitle);
public sealed record TimeAdjustmentPreview(
string BlockType,
string? Winner,
long CappedWhite,
long CappedBlack,
long WhiteDelta,
long BlackDelta,
long WhiteAfter,
long BlackAfter);

View File

@@ -0,0 +1,167 @@
using ChessCubing.App.Models;
namespace ChessCubing.App.Models.Social;
public sealed class SocialOverviewResponse
{
public SocialFriendResponse[] Friends { get; init; } = [];
public SocialInvitationResponse[] ReceivedInvitations { get; init; } = [];
public SocialInvitationResponse[] SentInvitations { get; init; } = [];
}
public sealed class SocialFriendResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public bool IsOnline { get; init; }
}
public sealed class SocialInvitationResponse
{
public long InvitationId { get; init; }
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public bool IsOnline { get; init; }
public DateTime CreatedUtc { get; init; }
}
public sealed class SocialSearchUserResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public bool IsOnline { get; init; }
public bool IsFriend { get; init; }
public bool HasSentInvitation { get; init; }
public bool HasReceivedInvitation { get; init; }
}
public sealed class SendFriendInvitationRequest
{
public string TargetSubject { get; init; } = string.Empty;
}
public sealed class PresenceSnapshotMessage
{
public string[] OnlineSubjects { get; init; } = [];
}
public sealed class PresenceChangedMessage
{
public string Subject { get; init; } = string.Empty;
public bool IsOnline { get; init; }
}
public sealed class PlayInviteMessage
{
public string InviteId { get; init; } = string.Empty;
public string SenderSubject { get; init; } = string.Empty;
public string SenderUsername { get; init; } = string.Empty;
public string SenderDisplayName { get; init; } = string.Empty;
public string RecipientSubject { get; init; } = string.Empty;
public string RecipientUsername { get; init; } = string.Empty;
public string RecipientDisplayName { get; init; } = string.Empty;
public string RecipientColor { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; }
public DateTime ExpiresUtc { get; init; }
}
public sealed class PlayInviteClosedMessage
{
public string InviteId { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
}
public sealed class PlaySessionResponse
{
public string SessionId { get; init; } = string.Empty;
public string WhiteSubject { get; init; } = string.Empty;
public string WhiteName { get; init; } = string.Empty;
public string BlackSubject { get; init; } = string.Empty;
public string BlackName { get; init; } = string.Empty;
public string InitiatorSubject { get; init; } = string.Empty;
public string RecipientSubject { get; init; } = string.Empty;
public DateTime ConfirmedUtc { get; init; }
}
public sealed class CollaborativeMatchStateMessage
{
public string SessionId { get; init; } = string.Empty;
public string? MatchJson { get; init; }
public string Route { get; init; } = "/application.html";
public string SenderSubject { get; init; } = string.Empty;
public long Revision { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed class CollaborativeMatchSnapshot
{
public string SessionId { get; init; } = string.Empty;
public MatchState? Match { get; init; }
public string Route { get; init; } = "/application.html";
public string SenderSubject { get; init; } = string.Empty;
public long Revision { get; init; }
public DateTime UpdatedUtc { get; init; }
}

View File

@@ -0,0 +1,138 @@
namespace ChessCubing.App.Models.Stats;
public sealed class UserStatsResponse
{
public string Subject { get; init; } = string.Empty;
public int CurrentElo { get; init; }
public int RankedGames { get; init; }
public int CasualGames { get; init; }
public int Wins { get; init; }
public int Losses { get; init; }
public int StoppedGames { get; init; }
public int WhiteWins { get; init; }
public int BlackWins { get; init; }
public int WhiteLosses { get; init; }
public int BlackLosses { get; init; }
public int TotalMoves { get; init; }
public int TotalCubeRounds { get; init; }
public long? BestCubeTimeMs { get; init; }
public long? AverageCubeTimeMs { get; init; }
public DateTime? LastMatchUtc { get; init; }
public UserRecentMatchResponse[] RecentMatches { get; init; } = [];
}
public sealed class UserRecentMatchResponse
{
public string MatchId { get; init; } = string.Empty;
public DateTime CompletedUtc { get; init; }
public string Result { get; init; } = string.Empty;
public string Mode { get; init; } = string.Empty;
public string Preset { get; init; } = string.Empty;
public string? MatchLabel { get; init; }
public string PlayerColor { get; init; } = string.Empty;
public string PlayerName { get; init; } = string.Empty;
public string OpponentName { get; init; } = string.Empty;
public string? OpponentSubject { get; init; }
public bool IsRanked { get; init; }
public bool IsWin { get; init; }
public bool IsLoss { get; init; }
public int PlayerMoves { get; init; }
public int OpponentMoves { get; init; }
public int CubeRounds { get; init; }
public long? PlayerBestCubeTimeMs { get; init; }
public long? PlayerAverageCubeTimeMs { get; init; }
public int? EloBefore { get; init; }
public int? EloAfter { get; init; }
public int? EloDelta { get; init; }
}
public sealed class ReportCompletedMatchRequest
{
public string MatchId { get; init; } = string.Empty;
public string? CollaborationSessionId { get; init; }
public string? WhiteSubject { get; init; }
public string WhiteName { get; init; } = string.Empty;
public string? BlackSubject { get; init; }
public string BlackName { get; init; } = string.Empty;
public string Result { get; init; } = string.Empty;
public string Mode { get; init; } = string.Empty;
public string Preset { get; init; } = string.Empty;
public string? MatchLabel { get; init; }
public int BlockNumber { get; init; }
public int WhiteMoves { get; init; }
public int BlackMoves { get; init; }
public ReportCompletedCubeRound[] CubeRounds { get; init; } = [];
}
public sealed class ReportCompletedCubeRound
{
public int BlockNumber { get; init; }
public int? Number { get; init; }
public long? White { get; init; }
public long? Black { get; init; }
}
public sealed class ReportCompletedMatchResponse
{
public bool Recorded { get; init; }
public bool IsDuplicate { get; init; }
public bool IsRanked { get; init; }
public int? WhiteEloAfter { get; init; }
public int? BlackEloAfter { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace ChessCubing.App.Models.Users;
public sealed class AdminCreateUserRequest
{
public string Username { get; set; } = string.Empty;
public string? Email { get; set; }
public string Password { get; set; } = string.Empty;
public string ConfirmPassword { get; set; } = string.Empty;
public string? FirstName { get; set; }
public string? LastName { get; set; }
public bool IsEnabled { get; set; } = true;
public bool IsEmailVerified { get; set; }
public string? DisplayName { get; set; }
public string? Club { get; set; }
public string? City { get; set; }
public string? PreferredFormat { get; set; }
public string? FavoriteCube { get; set; }
public string? Bio { get; set; }
}

View File

@@ -0,0 +1,26 @@
namespace ChessCubing.App.Models.Users;
public sealed class AdminUpdateUserRequest
{
public string? Email { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public bool IsEnabled { get; set; }
public bool IsEmailVerified { get; set; }
public string? DisplayName { get; set; }
public string? Club { get; set; }
public string? City { get; set; }
public string? PreferredFormat { get; set; }
public string? FavoriteCube { get; set; }
public string? Bio { get; set; }
}

View File

@@ -0,0 +1,40 @@
namespace ChessCubing.App.Models.Users;
public sealed class AdminUserDetailResponse
{
public string Subject { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string? Email { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string IdentityDisplayName { get; set; } = string.Empty;
public bool IsEnabled { get; set; }
public bool IsEmailVerified { get; set; }
public DateTime? AccountCreatedUtc { get; set; }
public bool HasSiteProfile { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string? Club { get; set; }
public string? City { get; set; }
public string? PreferredFormat { get; set; }
public string? FavoriteCube { get; set; }
public string? Bio { get; set; }
public DateTime? SiteProfileCreatedUtc { get; set; }
public DateTime? SiteProfileUpdatedUtc { get; set; }
}

View File

@@ -0,0 +1,30 @@
namespace ChessCubing.App.Models.Users;
public sealed class AdminUserSummaryResponse
{
public string Subject { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string? Email { get; set; }
public string IdentityDisplayName { get; set; } = string.Empty;
public string? SiteDisplayName { get; set; }
public bool IsEnabled { get; set; }
public bool IsEmailVerified { get; set; }
public bool HasSiteProfile { get; set; }
public string? Club { get; set; }
public string? City { get; set; }
public string? PreferredFormat { get; set; }
public DateTime? AccountCreatedUtc { get; set; }
public DateTime? SiteProfileUpdatedUtc { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace ChessCubing.App.Models.Users;
public sealed class UpdateUserProfileRequest
{
public string? DisplayName { get; set; }
public string? Club { get; set; }
public string? City { get; set; }
public string? PreferredFormat { get; set; }
public string? FavoriteCube { get; set; }
public string? Bio { get; set; }
}

View File

@@ -0,0 +1,26 @@
namespace ChessCubing.App.Models.Users;
public sealed class UserProfileResponse
{
public string Subject { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string? Email { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string? Club { get; set; }
public string? City { get; set; }
public string? PreferredFormat { get; set; }
public string? FavoriteCube { get; set; }
public string? Bio { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,957 @@
@page "/application"
@page "/application.html"
@using System.Net
@using System.Net.Http.Json
@using System.Security.Claims
@using ChessCubing.App.Models.Users
@implements IDisposable
@inject BrowserBridge Browser
@inject MatchStore Store
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject HttpClient Http
@inject SocialRealtimeService Realtime
<PageTitle>ChessCubing Arena | Application</PageTitle>
<PageBody Page="setup" BodyClass="@SetupBodyClass" />
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="setup-shell">
<header class="hero hero-setup">
<div class="hero-copy">
<a class="logo-lockup" href="index.html" aria-label="Accueil ChessCubing">
<img class="hero-logo-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="hero-logo" src="transparent.png" alt="Logo ChessCubing" />
</a>
<p class="eyebrow">Application officielle de match</p>
<h1>ChessCubing Arena</h1>
<div class="hero-actions">
<a class="button ghost" href="index.html">Accueil du site</a>
<a class="button secondary" href="reglement.html">Consulter le reglement</a>
</div>
</div>
<aside class="hero-preview">
<div class="preview-card">
<p class="micro-label">Flux de match</p>
<ol class="phase-list">
<li>Configurer la rencontre</li>
<li>Passer a la page chrono</li>
<li>Basculer automatiquement sur la page cube</li>
<li>Revenir sur la page chrono pour le Block suivant</li>
</ol>
</div>
</aside>
</header>
<main class="setup-grid">
<section class="panel">
<div class="section-heading">
<div>
<p class="eyebrow">Nouvelle rencontre</p>
<h2>Configuration</h2>
</div>
<p class="section-copy">
Les reglages ci-dessous preparent les pages chrono et cube.
</p>
</div>
<form class="setup-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault="true" novalidate>
<label class="option-card competition-option span-2 @(Form.CompetitionMode ? "is-selected" : string.Empty)">
<input @bind="Form.CompetitionMode" id="competitionMode" name="competitionMode" type="checkbox" />
<strong>Mode competition</strong>
<span>
Affiche le nom de la rencontre, l'arbitre, le club ou l'evenement
et les notes d'organisation.
</span>
</label>
@if (Form.CompetitionMode)
{
<label class="field span-2" id="matchLabelField">
<span>Nom de la rencontre</span>
<input @bind="Form.MatchLabel" @bind:event="oninput" name="matchLabel" type="text" maxlength="80" placeholder="Open ChessCubing de Paris" />
</label>
}
<fieldset class="field span-2">
<legend>Mode officiel</legend>
<div class="option-grid mode-grid">
<label class="option-card @(Form.Mode == MatchEngine.ModeTwice ? "is-selected" : string.Empty)">
<input type="radio" name="mode" checked="@(Form.Mode == MatchEngine.ModeTwice)" @onchange="() => SetMode(MatchEngine.ModeTwice)" />
<strong>ChessCubing Twice</strong>
<span>
Le gagnant du cube ouvre le Block suivant et peut obtenir un
double coup V2.
</span>
</label>
<label class="option-card @(Form.Mode == MatchEngine.ModeTime ? "is-selected" : string.Empty)">
<input type="radio" name="mode" checked="@(Form.Mode == MatchEngine.ModeTime)" @onchange="() => SetMode(MatchEngine.ModeTime)" />
<strong>ChessCubing Time</strong>
<span>
Meme structure de Blocks, avec chronos cumules et alternance
bloc - / bloc +.
</span>
</label>
</div>
</fieldset>
<fieldset class="field span-2">
<legend>Cadence du match</legend>
<div class="option-grid preset-grid">
<label class="option-card @(Form.Preset == MatchEngine.PresetFast ? "is-selected" : string.Empty)">
<input type="radio" name="preset" checked="@(Form.Preset == MatchEngine.PresetFast)" @onchange="() => SetPreset(MatchEngine.PresetFast)" />
<strong>FAST</strong>
<span>6 coups par joueur</span>
</label>
<label class="option-card @(Form.Preset == MatchEngine.PresetFreeze ? "is-selected" : string.Empty)">
<input type="radio" name="preset" checked="@(Form.Preset == MatchEngine.PresetFreeze)" @onchange="() => SetPreset(MatchEngine.PresetFreeze)" />
<strong>FREEZE</strong>
<span>8 coups par joueur</span>
</label>
<label class="option-card @(Form.Preset == MatchEngine.PresetMasters ? "is-selected" : string.Empty)">
<input type="radio" name="preset" checked="@(Form.Preset == MatchEngine.PresetMasters)" @onchange="() => SetPreset(MatchEngine.PresetMasters)" />
<strong>MASTERS</strong>
<span>10 coups par joueur</span>
</label>
</div>
</fieldset>
<fieldset class="field span-2">
<legend>Temps personnalises</legend>
<div class="timing-grid">
<label class="field">
<span>Temps du Block (minutes)</span>
<input @bind="Form.BlockMinutes" name="blockMinutes" type="number" min="1" max="180" step="1" />
</label>
@if (!UsesMoveLimit)
{
<label class="field" id="timeInitialField">
<span>Temps de chaque joueur (minutes)</span>
<input @bind="Form.TimeInitialMinutes" name="timeInitialMinutes" type="number" min="1" max="180" step="1" />
</label>
}
@if (UsesMoveLimit)
{
<label class="field" id="moveSecondsField">
<span>Temps coup (secondes)</span>
<input @bind="Form.MoveSeconds" name="moveSeconds" type="number" min="5" max="300" step="1" />
</label>
}
</div>
</fieldset>
<label class="field player-name-field">
<div class="field-heading">
<span class="field-label-text">Joueur blanc</span>
@if (CanUseConnectedPlayerName)
{
<button class="button ghost small icon-button player-fill-button"
type="button"
title="@BuildPrefillTitle("blanc")"
aria-label="@BuildPrefillTitle("blanc")"
@onclick="FillWhiteWithConnectedPlayer">
<span class="material-icons action-icon" aria-hidden="true">account_circle</span>
</button>
}
</div>
<input @bind="WhitePlayerName" @bind:event="oninput" name="whiteName" type="text" maxlength="40" placeholder="Blanc" disabled="@HasLockedPlaySession" />
</label>
<label class="field player-name-field">
<div class="field-heading">
<span class="field-label-text">Joueur noir</span>
@if (CanUseConnectedPlayerName)
{
<button class="button ghost small icon-button player-fill-button"
type="button"
title="@BuildPrefillTitle("noir")"
aria-label="@BuildPrefillTitle("noir")"
@onclick="FillBlackWithConnectedPlayer">
<span class="material-icons action-icon" aria-hidden="true">account_circle</span>
</button>
}
</div>
<input @bind="BlackPlayerName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" disabled="@HasLockedPlaySession" />
</label>
@if (!string.IsNullOrWhiteSpace(SetupError))
{
<p class="profile-feedback error span-2">@SetupError</p>
}
@if (IsAuthenticated)
{
<section class="setup-social-card span-2">
<div class="social-card-head">
<div>
<span class="micro-label">Amis connectes</span>
<strong>Inviter un ami a jouer</strong>
</div>
<button class="button ghost small icon-button" type="button" title="Rafraichir les amis" aria-label="Rafraichir les amis" @onclick="LoadSocialOverviewAsync">
<span class="material-icons action-icon" aria-hidden="true">refresh</span>
</button>
</div>
<p class="social-empty">
Choisis rapidement un ami en ligne et decide de quel cote il doit etre pre-rempli.
</p>
@if (!string.IsNullOrWhiteSpace(SocialLoadError))
{
<p class="profile-feedback error">@SocialLoadError</p>
}
@if (!string.IsNullOrWhiteSpace(InviteActionError))
{
<p class="profile-feedback error">@InviteActionError</p>
}
@if (!string.IsNullOrWhiteSpace(Realtime.LastInviteNotice))
{
<div class="social-inline-feedback">
<span>@Realtime.LastInviteNotice</span>
<button class="button ghost small" type="button" @onclick="Realtime.ClearInviteNotice">Masquer</button>
</div>
}
@if (Realtime.OutgoingPlayInvite is not null)
{
<div class="play-invite-pending">
<div>
<span class="micro-label">En attente</span>
<strong>@Realtime.OutgoingPlayInvite.RecipientDisplayName</strong>
<p>Invitation envoyee pour jouer cote @(BuildColorLabel(Realtime.OutgoingPlayInvite.RecipientColor)).</p>
</div>
<button class="button ghost small" type="button" @onclick="CancelOutgoingPlayInviteAsync">
Annuler
</button>
</div>
}
@if (IsSocialLoading)
{
<p class="social-empty">Chargement des amis connectes...</p>
}
else if (OnlineFriends.Length > 0)
{
<div class="play-friends-list">
@foreach (var friend in OnlineFriends)
{
<article class="play-friend-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@friend.DisplayName</strong>
<span class="@BuildPresenceClass(friend.Subject, friend.IsOnline)">@BuildPresenceLabel(friend.Subject, friend.IsOnline)</span>
</div>
<span class="social-item-subtitle">@friend.Username</span>
</div>
<div class="play-friend-actions">
<button class="button ghost small" type="button" disabled="@(Realtime.OutgoingPlayInvite is not null)" @onclick="() => InviteFriendAsWhiteAsync(friend.Subject)">
Ami blanc
</button>
<button class="button ghost small" type="button" disabled="@(Realtime.OutgoingPlayInvite is not null)" @onclick="() => InviteFriendAsBlackAsync(friend.Subject)">
Ami noir
</button>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucun ami connecte. Gere tes amis sur la page utilisateur puis attends qu'ils se connectent.</p>
}
</section>
}
@if (Form.CompetitionMode)
{
<label class="field" id="arbiterField">
<span>Arbitre</span>
<input @bind="Form.ArbiterName" @bind:event="oninput" name="arbiterName" type="text" maxlength="40" placeholder="Arbitre principal" />
</label>
<label class="field" id="eventField">
<span>Club / evenement</span>
<input @bind="Form.EventName" @bind:event="oninput" name="eventName" type="text" maxlength="60" placeholder="Club, tournoi, demonstration" />
</label>
<label class="field span-2" id="notesField">
<span>Notes</span>
<textarea @bind="Form.Notes" @bind:event="oninput" name="notes" rows="3" placeholder="Tirage au sort effectue, 8 cubes verifies, variante prete..."></textarea>
</label>
}
<div id="setupSummary" class="setup-summary span-2">
<strong>@CurrentMode.Label</strong>
<span>@CurrentPreset.Description</span>
<span>@TimingText</span>
<span>@TimeImpact</span>
<span>@QuotaText</span>
<span>@StatsEligibilityText</span>
</div>
<div class="setup-actions span-2">
<button class="button primary" type="button" @onclick="HandleSubmit">Ouvrir la page chrono</button>
<button class="button secondary" id="loadDemoButton" type="button" @onclick="LoadDemo">Charger une demo</button>
<a class="button ghost" href="reglement.html">Consulter le reglement</a>
</div>
</form>
</section>
<aside class="panel side-panel">
<div class="section-heading">
<div>
<p class="eyebrow">Match en memoire</p>
<h2>Reprise rapide</h2>
</div>
</div>
@if (!_ready || CurrentMatch is null)
{
<div id="resumeCard" class="resume-card empty">
<p>Aucun match en cours pour l'instant.</p>
</div>
}
else
{
<div id="resumeCard" class="resume-card">
<strong>@ResumeMatchLabel(CurrentMatch)</strong>
<p>@CurrentMatchMode(CurrentMatch)</p>
<p>@($"{CurrentMatch.Config.WhiteName} vs {CurrentMatch.Config.BlackName}")</p>
<p>@ResumePhaseLabel(CurrentMatch)</p>
<div class="resume-actions">
<button class="button primary" id="resumeMatchButton" type="button" @onclick="ResumeMatch">
@(string.IsNullOrEmpty(CurrentMatch.Result) ? "Reprendre la phase" : "Voir le match")
</button>
<button class="button ghost" id="clearMatchButton" type="button" @onclick="ClearMatchAsync">Effacer le match</button>
</div>
</div>
}
<div class="rules-stack">
<article class="rule-card">
<span class="micro-label">Page chrono</span>
<strong>Gros boutons uniquement</strong>
<p>
Chaque joueur dispose d'une grande zone tactile pour signaler la
fin de son coup, puis l'app ouvre automatiquement la phase cube
quand le Block d'echecs est termine.
</p>
</article>
<article class="rule-card">
<span class="micro-label">Page cube</span>
<strong>Une page dediee</strong>
<p>
Les deux joueurs lancent et arretent leur propre chrono cube sur
un ecran separe, toujours en face-a-face sur mobile.
</p>
</article>
<article class="rule-card">
<span class="micro-label">Sources</span>
<strong>Reglements integres</strong>
<p>
<a href="reglement.html">Page reglement du site</a>
<br />
<a href="ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" target="_blank">Reglement ChessCubing Twice</a>
<br />
<a href="ChessCubing_Time_Reglement_Officiel_V1-1.pdf" target="_blank">Reglement ChessCubing Time</a>
</p>
</article>
</div>
</aside>
</main>
<div class="setup-refresh-footer">
<button class="refresh-link-button" id="refreshAppButton" type="button" @onclick="ForceRefreshAsync">
Rafraichir l'app
</button>
</div>
</div>
@code {
private SetupFormModel Form { get; set; } = new();
private bool _ready;
private bool IsAuthenticated;
private bool IsSocialLoading;
private int _knownSocialVersion;
private long _knownCollaborativeRevision;
private string? _appliedActiveSessionId;
private string? ConnectedPlayerName;
private string? ConnectedPlayerSubject;
private string? AssignedWhiteSubject;
private string? AssignedBlackSubject;
private string? SetupError;
private string? SocialLoadError;
private string? InviteActionError;
private SocialOverviewResponse? SocialOverview;
private MatchState? CurrentMatch => Store.Current;
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
private bool CanUseConnectedPlayerName => !string.IsNullOrWhiteSpace(ConnectedPlayerName);
private bool HasLockedPlaySession => Realtime.ActivePlaySession is not null;
private string WhitePlayerName
{
get => Form.WhiteName;
set
{
Form.WhiteName = value;
if (!HasLockedPlaySession &&
!string.IsNullOrWhiteSpace(AssignedWhiteSubject) &&
AssignedWhiteSubject == ConnectedPlayerSubject &&
!SamePlayerName(value, ConnectedPlayerName))
{
AssignedWhiteSubject = null;
}
SetupError = null;
}
}
private string BlackPlayerName
{
get => Form.BlackName;
set
{
Form.BlackName = value;
if (!HasLockedPlaySession &&
!string.IsNullOrWhiteSpace(AssignedBlackSubject) &&
AssignedBlackSubject == ConnectedPlayerSubject &&
!SamePlayerName(value, ConnectedPlayerName))
{
AssignedBlackSubject = null;
}
SetupError = null;
}
}
private SocialFriendResponse[] OnlineFriends
=> SocialOverview?.Friends
.Where(friend => ResolveOnlineStatus(friend.Subject, friend.IsOnline))
.OrderBy(friend => friend.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray()
?? [];
private bool UsesMoveLimit => MatchEngine.UsesMoveLimit(Form.Mode);
private MatchPresetInfo CurrentPreset =>
MatchEngine.Presets.TryGetValue(Form.Preset, out var preset)
? preset
: MatchEngine.Presets[MatchEngine.PresetFast];
private MatchModeInfo CurrentMode =>
MatchEngine.Modes.TryGetValue(Form.Mode, out var mode)
? mode
: MatchEngine.Modes[MatchEngine.ModeTwice];
private string TimingText =>
UsesMoveLimit
? $"Temps configures : Block {MatchEngine.FormatClock(Form.BlockMinutes * 60_000L)}, coup {MatchEngine.FormatClock(Form.MoveSeconds * 1_000L)}."
: $"Temps configures : Block {MatchEngine.FormatClock(Form.BlockMinutes * 60_000L)}, temps de chaque joueur {MatchEngine.FormatClock(Form.TimeInitialMinutes * 60_000L)}.";
private string TimeImpact =>
Form.Mode == MatchEngine.ModeTime
? $"Chronos cumules de {MatchEngine.FormatClock(Form.TimeInitialMinutes * 60_000L)} par joueur, ajustes apres chaque phase cube avec plafond de 120 s pris en compte. Aucun temps par coup en mode Time."
: "Le gagnant du cube commence le Block suivant, avec double coup V2 possible.";
private string QuotaText =>
UsesMoveLimit
? $"Quota actif : {CurrentPreset.Quota} coups par joueur."
: $"Quota actif : {CurrentPreset.Quota} coups par joueur et par Block.";
private string StatsEligibilityText =>
Realtime.ActivePlaySession is not null
? "Match classe : les deux comptes sont identifies, la partie mettra a jour l'Elo et les statistiques."
: !string.IsNullOrWhiteSpace(AssignedWhiteSubject) || !string.IsNullOrWhiteSpace(AssignedBlackSubject)
? "Match amical : la partie sera enregistree pour le ou les comptes lies, sans impact Elo."
: "Match local uniquement : aucun compte joueur n'est lie des deux cotes, rien ne sera ajoute aux statistiques serveur.";
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
await LoadApplicationContextAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
await Store.EnsureLoadedAsync();
if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId))
{
await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId);
}
_ready = true;
StateHasChanged();
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
=> _ = InvokeAsync(LoadApplicationContextAsync);
private void HandleRealtimeChanged()
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
private async Task HandleRealtimeChangedAsync()
{
ApplyAcceptedPlaySession();
await ApplyCollaborativeSyncAsync();
if (IsAuthenticated && _knownSocialVersion != Realtime.SocialVersion)
{
_knownSocialVersion = Realtime.SocialVersion;
await LoadSocialOverviewAsync();
return;
}
StateHasChanged();
}
private async Task LoadApplicationContextAsync()
{
string? fallbackName = null;
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity?.IsAuthenticated != true)
{
IsAuthenticated = false;
ConnectedPlayerName = null;
ConnectedPlayerSubject = null;
AssignedWhiteSubject = null;
AssignedBlackSubject = null;
ResetSocialState();
await InvokeAsync(StateHasChanged);
return;
}
IsAuthenticated = true;
fallbackName = BuildConnectedPlayerFallback(user);
ConnectedPlayerSubject = ResolveSubject(user);
var response = await Http.GetAsync("api/users/me");
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
IsAuthenticated = false;
ConnectedPlayerName = null;
ConnectedPlayerSubject = null;
AssignedWhiteSubject = null;
AssignedBlackSubject = null;
ResetSocialState();
await InvokeAsync(StateHasChanged);
return;
}
ConnectedPlayerName = fallbackName;
await LoadSocialOverviewAsync();
await InvokeAsync(StateHasChanged);
return;
}
var profile = await response.Content.ReadFromJsonAsync<UserProfileResponse>();
ConnectedPlayerName = !string.IsNullOrWhiteSpace(profile?.DisplayName)
? profile.DisplayName
: fallbackName;
await LoadSocialOverviewAsync();
}
catch
{
ConnectedPlayerName = fallbackName;
}
ApplyAcceptedPlaySession();
await ApplyCollaborativeSyncAsync();
await InvokeAsync(StateHasChanged);
}
private async Task LoadSocialOverviewAsync()
{
if (!IsAuthenticated)
{
ResetSocialState();
return;
}
IsSocialLoading = true;
SocialLoadError = null;
try
{
var response = await Http.GetAsync("api/social/overview");
if (!response.IsSuccessStatusCode)
{
SocialLoadError = response.StatusCode switch
{
HttpStatusCode.Unauthorized => "La session a expire. Reconnecte-toi puis recharge la page.",
_ => "Le chargement des amis a echoue.",
};
SocialOverview = null;
return;
}
SocialOverview = await response.Content.ReadFromJsonAsync<SocialOverviewResponse>() ?? new SocialOverviewResponse();
_knownSocialVersion = Realtime.SocialVersion;
}
catch (HttpRequestException)
{
SocialLoadError = "Le service social est temporairement indisponible.";
SocialOverview = null;
}
catch (TaskCanceledException)
{
SocialLoadError = "Le chargement des amis a pris trop de temps.";
SocialOverview = null;
}
finally
{
IsSocialLoading = false;
StateHasChanged();
}
}
private async Task HandleSubmit()
{
SetupError = ValidateDistinctPlayers();
if (!string.IsNullOrWhiteSpace(SetupError))
{
StateHasChanged();
return;
}
await Store.EnsureLoadedAsync();
var match = MatchEngine.CreateMatch(Form.ToMatchConfig());
if (Realtime.ActivePlaySession is { } session)
{
match.CollaborationSessionId = session.SessionId;
match.WhiteSubject = NormalizeOptional(session.WhiteSubject);
match.BlackSubject = NormalizeOptional(session.BlackSubject);
match.Config.WhiteName = session.WhiteName;
match.Config.BlackName = session.BlackName;
await Realtime.EnsureJoinedPlaySessionAsync(match.CollaborationSessionId);
}
else
{
match.WhiteSubject = NormalizeOptional(AssignedWhiteSubject);
match.BlackSubject = NormalizeOptional(AssignedBlackSubject);
}
Store.SetCurrent(match);
try
{
await Store.SaveAsync();
await Realtime.PublishMatchStateAsync(match, "/chrono.html");
}
catch
{
// La navigation vers la phase chrono doit rester possible meme si la persistence
// du navigateur echoue ponctuellement.
}
Navigation.NavigateTo("/chrono.html");
}
private void LoadDemo()
{
Form = SetupFormModel.CreateDemo();
AssignedWhiteSubject = null;
AssignedBlackSubject = null;
SetupError = null;
}
private void FillWhiteWithConnectedPlayer()
{
if (!CanUseConnectedPlayerName)
{
return;
}
AssignConnectedPlayerToWhite();
}
private void FillBlackWithConnectedPlayer()
{
if (!CanUseConnectedPlayerName)
{
return;
}
AssignConnectedPlayerToBlack();
}
private async Task InviteFriendToPlayAsync(string friendSubject, string recipientColor)
{
InviteActionError = null;
try
{
await Realtime.SendPlayInviteAsync(friendSubject, recipientColor);
}
catch (InvalidOperationException exception)
{
InviteActionError = exception.Message;
}
catch (Exception exception)
{
InviteActionError = string.IsNullOrWhiteSpace(exception.Message)
? "L'invitation de partie n'a pas pu etre envoyee."
: exception.Message;
}
}
private async Task CancelOutgoingPlayInviteAsync()
{
InviteActionError = null;
try
{
await Realtime.CancelOutgoingPlayInviteAsync();
}
catch (Exception exception)
{
InviteActionError = string.IsNullOrWhiteSpace(exception.Message)
? "L'invitation de partie n'a pas pu etre annulee."
: exception.Message;
}
}
private Task InviteFriendAsWhiteAsync(string friendSubject)
=> InviteFriendToPlayAsync(friendSubject, "white");
private Task InviteFriendAsBlackAsync(string friendSubject)
=> InviteFriendToPlayAsync(friendSubject, "black");
private void SetMode(string mode)
=> Form.Mode = mode;
private void SetPreset(string preset)
=> Form.Preset = preset;
private void ResumeMatch()
{
if (CurrentMatch is null)
{
return;
}
Navigation.NavigateTo(MatchEngine.RouteForMatch(CurrentMatch));
}
private async Task ClearMatchAsync()
{
var sessionId = CurrentMatch?.CollaborationSessionId;
await Store.ClearAsync();
if (!string.IsNullOrWhiteSpace(sessionId))
{
await Realtime.PublishMatchStateAsync(null, "/application.html");
}
StateHasChanged();
}
private Task ForceRefreshAsync()
=> Browser.ForceRefreshAsync("/application.html").AsTask();
private static string ResumeMatchLabel(MatchState match)
=> string.IsNullOrWhiteSpace(match.Config.MatchLabel) ? "Rencontre ChessCubing" : match.Config.MatchLabel;
private static string CurrentMatchMode(MatchState match)
=> MatchEngine.Modes.TryGetValue(match.Config.Mode, out var mode) ? mode.Label : "ChessCubing Twice";
private static string ResumePhaseLabel(MatchState match)
{
if (!string.IsNullOrEmpty(match.Result))
{
return MatchEngine.ResultText(match);
}
return match.Phase == MatchEngine.PhaseCube ? "Page cube prete" : "Page chrono prete";
}
private string BuildPrefillTitle(string color)
=> $"Utiliser mon nom cote {color}";
private void ApplyAcceptedPlaySession()
{
var session = Realtime.ActivePlaySession;
if (session is null || string.Equals(_appliedActiveSessionId, session.SessionId, StringComparison.Ordinal))
{
return;
}
Form.WhiteName = session.WhiteName;
Form.BlackName = session.BlackName;
AssignedWhiteSubject = NormalizeOptional(session.WhiteSubject);
AssignedBlackSubject = NormalizeOptional(session.BlackSubject);
SetupError = null;
_appliedActiveSessionId = session.SessionId;
}
private void AssignConnectedPlayerToWhite()
{
if (!CanUseConnectedPlayerName)
{
return;
}
var connectedName = ConnectedPlayerName!;
Form.WhiteName = connectedName;
AssignedWhiteSubject = NormalizeOptional(ConnectedPlayerSubject);
if (SamePlayerName(Form.BlackName, connectedName) || string.Equals(AssignedBlackSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
{
Form.BlackName = "Noir";
AssignedBlackSubject = null;
}
SetupError = null;
}
private void AssignConnectedPlayerToBlack()
{
if (!CanUseConnectedPlayerName)
{
return;
}
var connectedName = ConnectedPlayerName!;
Form.BlackName = connectedName;
AssignedBlackSubject = NormalizeOptional(ConnectedPlayerSubject);
if (SamePlayerName(Form.WhiteName, connectedName) || string.Equals(AssignedWhiteSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
{
Form.WhiteName = "Blanc";
AssignedWhiteSubject = null;
}
SetupError = null;
}
private string? ValidateDistinctPlayers()
{
var whiteName = NormalizePlayerName(Form.WhiteName, "Blanc");
var blackName = NormalizePlayerName(Form.BlackName, "Noir");
return SamePlayerName(whiteName, blackName)
? "Le meme joueur ne peut pas etre renseigne des deux cotes. Choisis deux noms differents pour Blanc et Noir."
: null;
}
private async Task ApplyCollaborativeSyncAsync()
{
var snapshot = Realtime.CollaborativeSnapshot;
if (snapshot is null || snapshot.Revision <= _knownCollaborativeRevision)
{
return;
}
_knownCollaborativeRevision = snapshot.Revision;
Store.ReplaceCurrent(snapshot.Match);
await Store.SaveAsync();
if (snapshot.Match is not null)
{
Form.WhiteName = snapshot.Match.Config.WhiteName;
Form.BlackName = snapshot.Match.Config.BlackName;
AssignedWhiteSubject = NormalizeOptional(snapshot.Match.WhiteSubject);
AssignedBlackSubject = NormalizeOptional(snapshot.Match.BlackSubject);
SetupError = null;
}
var route = NormalizeRoute(snapshot.Route);
if (!string.Equals(route, "/application.html", StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo(route, replace: true);
}
}
private bool ResolveOnlineStatus(string subject, bool fallbackStatus)
=> Realtime.GetKnownOnlineStatus(subject) ?? fallbackStatus;
private string BuildPresenceClass(string subject, bool fallbackStatus)
=> ResolveOnlineStatus(subject, fallbackStatus)
? "presence-badge online"
: "presence-badge";
private string BuildPresenceLabel(string subject, bool fallbackStatus)
=> ResolveOnlineStatus(subject, fallbackStatus)
? "En ligne"
: "Hors ligne";
private static string BuildColorLabel(string color)
=> string.Equals(color, "white", StringComparison.OrdinalIgnoreCase)
? "blanc"
: "noir";
private static string NormalizePlayerName(string? value, string fallback)
=> MatchEngine.SanitizeText(value) is { Length: > 0 } normalized
? normalized
: fallback;
private static bool SamePlayerName(string? left, string? right)
=> string.Equals(
MatchEngine.SanitizeText(left) ?? string.Empty,
MatchEngine.SanitizeText(right) ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
private static string NormalizeRoute(string? route)
{
var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim();
return normalized.StartsWith('/') ? normalized : $"/{normalized}";
}
private void ResetSocialState()
{
SocialOverview = null;
SocialLoadError = null;
InviteActionError = null;
_knownSocialVersion = Realtime.SocialVersion;
_knownCollaborativeRevision = 0;
}
private static string? BuildConnectedPlayerFallback(ClaimsPrincipal user)
=> FirstNonEmpty(
user.FindFirst("name")?.Value,
user.FindFirst(ClaimTypes.Name)?.Value,
user.FindFirst("preferred_username")?.Value,
user.FindFirst(ClaimTypes.Email)?.Value);
private static string? ResolveSubject(ClaimsPrincipal user)
=> user.FindFirst("sub")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
private static string? FirstNonEmpty(params string?[] candidates)
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
public void Dispose()
{
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
Realtime.Changed -= HandleRealtimeChanged;
}
}

View File

@@ -0,0 +1,16 @@
@page "/authentication/{action}"
<main class="rules-shell">
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
<p class="eyebrow">Authentification</p>
<div>
<strong>Le systeme d'authentification est maintenant integre a l'application.</strong>
<p>Utilise les boutons du menu en haut de page pour te connecter ou creer un compte sans quitter le site.</p>
</div>
</section>
</main>
@code {
[Parameter]
public string? Action { get; set; }
}

View File

@@ -0,0 +1,682 @@
@page "/chrono"
@page "/chrono.html"
@implements IAsyncDisposable
@inject MatchStore Store
@inject NavigationManager Navigation
@inject SocialRealtimeService Realtime
@inject MatchStatsService MatchStats
<PageTitle>ChessCubing Arena | Phase Chrono</PageTitle>
<PageBody Page="chrono" BodyClass="@ChronoBodyClass" />
@{
var summary = Match is null ? null : BuildSummary();
var blackZone = Match is null ? null : BuildZone(MatchEngine.ColorBlack);
var whiteZone = Match is null ? null : BuildZone(MatchEngine.ColorWhite);
}
@if (!_ready)
{
<main class="phase-shell chrono-stage">
<div class="panel" style="padding: 1.5rem;">Chargement de la phase chrono...</div>
</main>
}
else if (Match is not null && summary is not null && blackZone is not null && whiteZone is not null)
{
<main class="phase-shell chrono-stage">
<header class="phase-header">
<a class="brand-link brand-link-logo" href="application.html" aria-label="Retour a l'application">
<img class="brand-link-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="brand-link-mark" src="transparent.png" alt="Logo ChessCubing" />
</a>
<div class="phase-title">
<p class="eyebrow">Phase chrono</p>
<h1 id="chronoTitle">@summary.Title</h1>
<p id="chronoSubtitle" class="phase-subtitle">@summary.Subtitle</p>
</div>
<button class="button ghost small utility-button" id="openArbiterButton" type="button" @onclick="OpenArbiterModal">
Arbitre
</button>
</header>
<section class="status-strip">
<article class="status-card">
<span id="blockTimerLabel">Temps Block</span>
<strong id="blockTimer">@summary.BlockTimer</strong>
</article>
<article class="status-card" id="moveTimerCard" hidden="@summary.HideMoveTimer">
<span>Temps coup</span>
<strong id="moveTimer">@summary.MoveTimer</strong>
</article>
<article class="status-card wide">
<span id="chronoCenterLabel">@summary.CenterLabel</span>
<strong id="chronoCenterValue">@summary.CenterValue</strong>
</article>
</section>
<section class="faceoff-board">
<article class="player-zone opponent-zone @(blackZone.ActiveZone ? "active-zone" : string.Empty) @(blackZone.HasPlayerClock ? "has-player-clock" : string.Empty)" id="blackZone">
<div class="zone-inner mirrored-mobile">
<div class="zone-head">
<div>
<span class="seat-tag dark-seat">Noir</span>
<h2 id="blackNameChrono">@blackZone.Name</h2>
</div>
<div class="zone-stats">
<strong id="blackMovesChrono">@blackZone.Moves</strong>
<span class="player-clock @(blackZone.NegativeClock ? "negative-clock" : string.Empty) @(blackZone.ActiveClock ? "active-clock" : string.Empty)" id="blackClockChrono">@blackZone.Clock</span>
</div>
</div>
<button class="zone-button dark-button @(blackZone.ActiveTurn ? "active-turn" : string.Empty)" id="blackMoveButton" type="button" disabled="@blackZone.Disabled" @onclick="() => HandleChronoTapAsync(MatchEngine.ColorBlack)">
@blackZone.ButtonText
</button>
<p class="zone-foot" id="blackHintChrono">@blackZone.Hint</p>
</div>
</article>
<article class="phase-spine">
<div class="spine-card">
<p class="micro-label" id="spineLabel">@summary.SpineLabel</p>
<strong id="spineHeadline">@summary.SpineHeadline</strong>
<p id="spineText">@summary.SpineText</p>
</div>
<button class="button primary spine-button" id="primaryChronoButton" type="button" @onclick="HandlePrimaryActionAsync">
@summary.PrimaryButtonText
</button>
</article>
<article class="player-zone @(whiteZone.ActiveZone ? "active-zone" : string.Empty) @(whiteZone.HasPlayerClock ? "has-player-clock" : string.Empty)" id="whiteZone">
<div class="zone-inner">
<div class="zone-head">
<div>
<span class="seat-tag light-seat">Blanc</span>
<h2 id="whiteNameChrono">@whiteZone.Name</h2>
</div>
<div class="zone-stats">
<strong id="whiteMovesChrono">@whiteZone.Moves</strong>
<span class="player-clock @(whiteZone.NegativeClock ? "negative-clock" : string.Empty) @(whiteZone.ActiveClock ? "active-clock" : string.Empty)" id="whiteClockChrono">@whiteZone.Clock</span>
</div>
</div>
<button class="zone-button light-button @(whiteZone.ActiveTurn ? "active-turn" : string.Empty)" id="whiteMoveButton" type="button" disabled="@whiteZone.Disabled" @onclick="() => HandleChronoTapAsync(MatchEngine.ColorWhite)">
@whiteZone.ButtonText
</button>
<p class="zone-foot" id="whiteHintChrono">@whiteZone.Hint</p>
</div>
</article>
</section>
</main>
<section class="modal @(ShowArbiterModal ? string.Empty : "hidden")" id="arbiterModal" aria-hidden="@BoolString(!ShowArbiterModal)">
<div class="modal-backdrop" @onclick="CloseArbiterModal"></div>
<div class="modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Outils arbitre</p>
<h2>Controles avances</h2>
</div>
<button class="button ghost small" id="closeArbiterButton" type="button" @onclick="CloseArbiterModal">Fermer</button>
</div>
<p class="section-copy" id="arbiterStatus">@summary.ArbiterStatus</p>
<div class="modal-actions">
<button class="button secondary" id="arbiterPauseButton" type="button" @onclick="TogglePauseAsync">
Pause / reprise
</button>
<button class="button secondary" id="arbiterCloseBlockButton" type="button" @onclick="CloseBlockAsync">
Passer au cube
</button>
<button class="button secondary" id="arbiterTimeoutButton" type="button" hidden="@summary.HideMoveTimer" disabled="@summary.HideMoveTimer" @onclick="TimeoutMoveAsync">
@summary.TimeoutButtonText
</button>
<button class="button secondary" id="arbiterSwitchTurnButton" type="button" @onclick="SwitchTurnAsync">
Corriger le trait
</button>
<button class="button ghost" id="arbiterWhiteWinButton" type="button" @onclick="() => SetResultAsync(MatchEngine.ColorWhite)">
Blanc gagne
</button>
<button class="button ghost" id="arbiterBlackWinButton" type="button" @onclick="() => SetResultAsync(MatchEngine.ColorBlack)">
Noir gagne
</button>
<button class="button ghost danger" id="arbiterStopButton" type="button" @onclick='() => SetResultAsync("stopped")'>
Abandon / arret
</button>
<button class="button ghost" id="arbiterResetButton" type="button" @onclick="ResetMatchAsync">
Reinitialiser le match
</button>
</div>
</div>
</section>
}
@code {
private CancellationTokenSource? _tickerCancellation;
private bool _ready;
private bool ShowArbiterModal;
private long _knownCollaborativeRevision;
private MatchState? Match => Store.Current;
private string ChronoBodyClass =>
Match is not null && MatchEngine.IsTimeMode(Match)
? "phase-body time-mode"
: "phase-body";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
await Store.EnsureLoadedAsync();
if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId))
{
await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId);
await ApplyCollaborativeSyncAsync();
}
_ready = true;
if (Match is null)
{
Navigation.NavigateTo("/application.html", replace: true);
return;
}
if (string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube)
{
await Store.SaveAsync();
Navigation.NavigateTo("/cube.html", replace: true);
return;
}
await EnsureResultReportedAsync();
_tickerCancellation = new CancellationTokenSource();
_ = RunTickerAsync(_tickerCancellation.Token);
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
if (_tickerCancellation is not null)
{
_tickerCancellation.Cancel();
_tickerCancellation.Dispose();
}
Realtime.Changed -= HandleRealtimeChanged;
await Store.FlushIfDueAsync(0);
}
private void HandleRealtimeChanged()
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
private async Task HandleRealtimeChangedAsync()
{
await ApplyCollaborativeSyncAsync();
await EnsureResultReportedAsync();
StateHasChanged();
}
private async Task RunTickerAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
var match = Match;
if (match is null)
{
continue;
}
if (MatchEngine.SyncRunningState(match))
{
Store.MarkDirty();
}
await Store.FlushIfDueAsync();
if (string.IsNullOrEmpty(match.Result) && match.Phase == MatchEngine.PhaseCube)
{
await Store.SaveAsync();
await InvokeAsync(() => Navigation.NavigateTo("/cube.html", replace: true));
return;
}
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
}
private void OpenArbiterModal()
=> ShowArbiterModal = true;
private void CloseArbiterModal()
=> ShowArbiterModal = false;
private async Task HandleChronoTapAsync(string color)
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
{
return;
}
MatchEngine.SyncRunningState(match);
if (!match.Running || match.CurrentTurn != color)
{
return;
}
if (match.DoubleCoup.Step == 1)
{
MatchEngine.RegisterFreeDoubleMove(match);
}
else
{
MatchEngine.RegisterCountedMove(match, match.DoubleCoup.Step == 2 ? "double" : "standard");
}
await PersistAndRouteAsync();
}
private async Task HandlePrimaryActionAsync()
{
var match = Match;
if (match is null)
{
return;
}
MatchEngine.SyncRunningState(match);
if (!string.IsNullOrEmpty(match.Result))
{
Navigation.NavigateTo("/application.html");
return;
}
if (match.Phase != MatchEngine.PhaseBlock)
{
await Store.SaveAsync();
Navigation.NavigateTo("/cube.html", replace: true);
return;
}
if (match.Running)
{
MatchEngine.PauseBlock(match);
}
else
{
MatchEngine.StartBlock(match);
}
await PersistAndRouteAsync();
}
private async Task TogglePauseAsync()
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
{
return;
}
MatchEngine.SyncRunningState(match);
if (match.Running)
{
MatchEngine.PauseBlock(match);
}
else
{
MatchEngine.StartBlock(match);
}
await PersistAndRouteAsync();
}
private async Task CloseBlockAsync()
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
{
return;
}
MatchEngine.SyncRunningState(match);
MatchEngine.RequestBlockClosure(match, $"Cloture manuelle {MatchEngine.GetBlockGenitivePhrase(match)} demandee par l'arbitre.");
await PersistAndRouteAsync();
}
private async Task TimeoutMoveAsync()
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock || !MatchEngine.UsesMoveLimit(match))
{
return;
}
MatchEngine.SyncRunningState(match);
MatchEngine.RegisterMoveTimeout(match, false);
await PersistAndRouteAsync();
}
private async Task SwitchTurnAsync()
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
{
return;
}
MatchEngine.SyncRunningState(match);
match.CurrentTurn = MatchEngine.OpponentOf(match.CurrentTurn);
if (MatchEngine.UsesMoveLimit(match))
{
match.MoveRemainingMs = MatchEngine.GetMoveLimitMs(match);
}
MatchEngine.LogEvent(match, "Trait corrige manuellement par l'arbitre.");
await PersistAndRouteAsync();
}
private async Task SetResultAsync(string result)
{
var match = Match;
if (match is null)
{
return;
}
MatchEngine.SyncRunningState(match);
MatchEngine.SetResult(match, result);
await PersistAndRouteAsync();
}
private async Task ResetMatchAsync()
{
var sessionId = Match?.CollaborationSessionId;
await Store.ClearAsync();
if (!string.IsNullOrWhiteSpace(sessionId))
{
await Realtime.PublishMatchStateAsync(null, "/application.html");
}
Navigation.NavigateTo("/application.html", replace: true);
}
private async Task PersistAndRouteAsync()
{
await EnsureResultReportedAsync();
Store.MarkDirty();
await Store.SaveAsync();
var route = Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube
? "/cube.html"
: "/chrono.html";
await Realtime.PublishMatchStateAsync(Match, route);
if (Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube)
{
Navigation.NavigateTo("/cube.html", replace: true);
return;
}
StateHasChanged();
}
private async Task EnsureResultReportedAsync()
{
var match = Match;
if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
{
return;
}
if (await MatchStats.TryReportCompletedMatchAsync(match))
{
Store.MarkDirty();
}
}
private ChronoSummaryView BuildSummary()
{
var match = Match!;
var blockHeading = MatchEngine.FormatBlockHeading(match, match.BlockNumber);
var subtitle = $"{blockHeading} - {MatchEngine.Modes[match.Config.Mode].Label} - {MatchEngine.RenderModeContext(match)}";
var hideMoveTimer = MatchEngine.IsTimeMode(match);
var timeoutButtonText = $"Depassement {MatchEngine.FormatClock(MatchEngine.GetMoveLimitMs(match))}";
if (!string.IsNullOrEmpty(match.Result))
{
return new ChronoSummaryView(
match.Config.MatchLabel,
subtitle,
MatchEngine.FormatClock(match.BlockRemainingMs),
hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs),
"Resultat",
MatchEngine.ResultText(match),
"Termine",
MatchEngine.ResultText(match),
"Retournez a la configuration pour lancer une nouvelle rencontre.",
"Retour a l'accueil",
"Le match est termine. Vous pouvez revenir a l'accueil ou reinitialiser.",
hideMoveTimer,
timeoutButtonText);
}
if (match.Running)
{
return new ChronoSummaryView(
match.Config.MatchLabel,
subtitle,
MatchEngine.FormatClock(match.BlockRemainingMs),
hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs),
"Trait",
MatchEngine.PlayerName(match, match.CurrentTurn),
"Chrono en cours",
$"{blockHeading} actif",
"Chaque joueur tape sur sa grande zone quand son coup est termine. La page cube s'ouvrira automatiquement a la fin de la phase chess.",
"Pause arbitre",
$"{blockHeading} en cours. Joueur au trait : {MatchEngine.PlayerName(match, match.CurrentTurn)}.",
hideMoveTimer,
timeoutButtonText);
}
return new ChronoSummaryView(
match.Config.MatchLabel,
subtitle,
MatchEngine.FormatClock(match.BlockRemainingMs),
hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs),
"Trait",
MatchEngine.PlayerName(match, match.CurrentTurn),
MatchEngine.IsTimeMode(match) ? "Etat du Block" : "Pret",
blockHeading,
"Demarrez le Block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.",
"Demarrer le Block",
$"{blockHeading} pret. {MatchEngine.PlayerName(match, match.CurrentTurn)} commencera.",
hideMoveTimer,
timeoutButtonText);
}
private ChronoZoneView BuildZone(string color)
{
var match = Match!;
var active = match.CurrentTurn == color;
var clockValue = match.Clocks is not null
? color == MatchEngine.ColorWhite ? match.Clocks.White : match.Clocks.Black
: 0;
var clockText = match.Clocks is not null
? MatchEngine.FormatSignedClock(clockValue)
: $"Dernier cube {MatchEngine.RenderLastCube(match, color)}";
if (!string.IsNullOrEmpty(match.Result))
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
MatchEngine.ResultText(match),
"Le match est termine.",
true,
false,
false,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
false);
}
if (!match.Running)
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"Block en pause",
active
? $"{MatchEngine.GetBlockPhrase(match)} n'a pas encore demarre ou a ete mis en pause."
: $"{MatchEngine.PlayerName(match, match.CurrentTurn)} reprendra au demarrage.",
true,
false,
active,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
false);
}
if (!active)
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"Attends",
$"{MatchEngine.PlayerName(match, match.CurrentTurn)} est en train de jouer.",
true,
false,
false,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
false);
}
if (match.DoubleCoup.Step == 1)
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"1er coup gratuit",
"Ce coup ne compte pas et ne doit pas donner echec.",
false,
true,
true,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
match.Clocks is not null);
}
if (match.DoubleCoup.Step == 2)
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"2e coup du double",
"Ce coup compte dans le quota et l'echec redevient autorise.",
false,
true,
true,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
match.Clocks is not null);
}
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"J'ai fini mon coup",
"Tape des que ton coup est joue sur l'echiquier.",
false,
true,
true,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
match.Clocks is not null);
}
private static string BoolString(bool value)
=> value ? "true" : "false";
private async Task ApplyCollaborativeSyncAsync()
{
var snapshot = Realtime.CollaborativeSnapshot;
if (snapshot is null || snapshot.Revision <= _knownCollaborativeRevision)
{
return;
}
_knownCollaborativeRevision = snapshot.Revision;
Store.ReplaceCurrent(snapshot.Match);
await Store.SaveAsync();
var route = NormalizeRoute(snapshot.Route);
if (!string.Equals(route, "/chrono.html", StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo(route, replace: true);
}
}
private static string NormalizeRoute(string? route)
{
var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim();
return normalized.StartsWith('/') ? normalized : $"/{normalized}";
}
private sealed record ChronoSummaryView(
string Title,
string Subtitle,
string BlockTimer,
string MoveTimer,
string CenterLabel,
string CenterValue,
string SpineLabel,
string SpineHeadline,
string SpineText,
string PrimaryButtonText,
string ArbiterStatus,
bool HideMoveTimer,
string TimeoutButtonText);
private sealed record ChronoZoneView(
string Name,
string Moves,
string Clock,
string ButtonText,
string Hint,
bool Disabled,
bool ActiveTurn,
bool ActiveZone,
bool HasPlayerClock,
bool NegativeClock,
bool ActiveClock);
}

View File

@@ -0,0 +1,877 @@
@page "/cube"
@page "/cube.html"
@implements IAsyncDisposable
@inject BrowserBridge Browser
@inject MatchStore Store
@inject NavigationManager Navigation
@inject SocialRealtimeService Realtime
<PageTitle>ChessCubing Arena | Phase Cube</PageTitle>
<PageBody Page="cube" BodyClass="phase-body" />
@{
var summary = Match is null ? null : BuildSummary();
var blackZone = Match is null ? null : BuildZone(MatchEngine.ColorBlack);
var whiteZone = Match is null ? null : BuildZone(MatchEngine.ColorWhite);
var resultView = Match is null ? null : BuildResultView();
}
@if (!_ready)
{
<main class="phase-shell cube-shell">
<div class="panel" style="padding: 1.5rem;">Chargement de la phase cube...</div>
</main>
}
else if (Match is not null && summary is not null && blackZone is not null && whiteZone is not null)
{
<main class="phase-shell cube-shell">
<header class="phase-header">
<a class="brand-link brand-link-logo" href="application.html" aria-label="Retour a l'application">
<img class="brand-link-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="brand-link-mark" src="transparent.png" alt="Logo ChessCubing" />
</a>
<div class="phase-title">
<p class="eyebrow">Phase cube</p>
<h1 id="cubeTitle">@summary.Title</h1>
<p id="cubeSubtitle" class="phase-subtitle">@summary.Subtitle</p>
</div>
<button class="button ghost small utility-button" id="openCubeHelpButton" type="button" @onclick="OpenHelpModal">
Arbitre
</button>
</header>
<section class="status-strip">
<article class="status-card">
<span id="cubeBlockLabelText">Block</span>
<strong id="cubeBlockLabel">@Match.BlockNumber</strong>
</article>
<article class="status-card">
<span>Temps max</span>
<strong id="cubeElapsed">@MatchEngine.RenderCubeElapsed(Match)</strong>
</article>
<article class="status-card wide">
<span id="cubeCenterLabel">@summary.CenterLabel</span>
<strong id="cubeCenterValue">@summary.CenterValue</strong>
</article>
</section>
<section class="faceoff-board">
<article class="player-zone opponent-zone" id="blackCubeZone">
<div class="zone-inner mirrored-mobile">
<div class="zone-head">
<div>
<span class="seat-tag dark-seat">Noir</span>
<h2 id="blackNameCube">@blackZone.Name</h2>
</div>
<div class="zone-stats">
<strong id="blackCubeResult">@blackZone.ResultText</strong>
<span id="blackCubeCap">@blackZone.MetaText</span>
</div>
</div>
<button class="zone-button dark-button @(blackZone.HoldArmed && !blackZone.HoldReady ? "cube-hold-arming" : string.Empty) @(blackZone.HoldReady ? "cube-hold-ready" : string.Empty)" id="blackCubeButton" type="button" disabled="@blackZone.Disabled" style="@blackZone.ProgressStyle" @onpointerdown="() => HandleCubePointerDownAsync(MatchEngine.ColorBlack)" @onpointerup="() => HandleCubePointerUpAsync(MatchEngine.ColorBlack)" @onpointercancel="() => CancelCubeHold(MatchEngine.ColorBlack)" @onpointerleave="() => CancelCubeHold(MatchEngine.ColorBlack)">
@blackZone.ButtonText
</button>
<p class="zone-foot" id="blackHintCube">@blackZone.Hint</p>
</div>
</article>
<article class="phase-spine">
<div class="spine-card">
<p class="micro-label" id="cubeSpineLabel">@summary.SpineLabel</p>
<strong id="cubeSpineHeadline">@summary.SpineHeadline</strong>
<p id="cubeSpineText">@summary.SpineText</p>
</div>
<button class="button primary spine-button" id="primaryCubeButton" type="button" disabled="@summary.PrimaryDisabled" @onclick="HandlePrimaryActionAsync">
@summary.PrimaryButtonText
</button>
</article>
<article class="player-zone" id="whiteCubeZone">
<div class="zone-inner">
<div class="zone-head">
<div>
<span class="seat-tag light-seat">Blanc</span>
<h2 id="whiteNameCube">@whiteZone.Name</h2>
</div>
<div class="zone-stats">
<strong id="whiteCubeResult">@whiteZone.ResultText</strong>
<span id="whiteCubeCap">@whiteZone.MetaText</span>
</div>
</div>
<button class="zone-button light-button @(whiteZone.HoldArmed && !whiteZone.HoldReady ? "cube-hold-arming" : string.Empty) @(whiteZone.HoldReady ? "cube-hold-ready" : string.Empty)" id="whiteCubeButton" type="button" disabled="@whiteZone.Disabled" style="@whiteZone.ProgressStyle" @onpointerdown="() => HandleCubePointerDownAsync(MatchEngine.ColorWhite)" @onpointerup="() => HandleCubePointerUpAsync(MatchEngine.ColorWhite)" @onpointercancel="() => CancelCubeHold(MatchEngine.ColorWhite)" @onpointerleave="() => CancelCubeHold(MatchEngine.ColorWhite)">
@whiteZone.ButtonText
</button>
<p class="zone-foot" id="whiteHintCube">@whiteZone.Hint</p>
</div>
</article>
</section>
</main>
<section class="modal @(ShowHelpModal ? string.Empty : "hidden")" id="cubeHelpModal" aria-hidden="@BoolString(!ShowHelpModal)">
<div class="modal-backdrop" @onclick="CloseHelpModal"></div>
<div class="modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Outils arbitre</p>
<h2>Phase cube</h2>
</div>
<button class="button ghost small" id="closeCubeHelpButton" type="button" @onclick="CloseHelpModal">
Fermer
</button>
</div>
<p class="section-copy" id="cubeHelpStatus">@summary.HelpStatus</p>
<div class="modal-actions">
<button class="button secondary" id="replayCubeButton" type="button" @onclick="ReplayCubeAsync">
Rejouer la phase cube
</button>
<button class="button ghost danger" id="cubeResetButton" type="button" @onclick="ResetMatchAsync">
Reinitialiser le match
</button>
</div>
</div>
</section>
<section class="modal @(ShowResultModal && resultView is not null ? string.Empty : "hidden")" id="cubeResultModal" aria-hidden="@BoolString(!(ShowResultModal && resultView is not null))">
<div class="modal-backdrop" @onclick="CloseResultModal"></div>
@if (resultView is not null)
{
<div class="modal-card result-modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Fin de phase cube</p>
<h2 id="cubeResultModalTitle">@resultView.Title</h2>
</div>
<button class="button ghost small" id="closeCubeResultButton" type="button" @onclick="CloseResultModal">
Fermer
</button>
</div>
<p class="section-copy" id="cubeResultSummary">@resultView.Summary</p>
<div class="cube-result-overview">
<article class="result-pill-card">
<span>Vainqueur cube</span>
<strong id="cubeResultWinner">@resultView.Winner</strong>
</article>
<article class="result-pill-card">
<span>Suite</span>
<strong id="cubeResultOutcome">@resultView.Outcome</strong>
</article>
</div>
<div class="cube-result-player-grid">
<article class="cube-result-player-card">
<span class="seat-tag light-seat">Blanc</span>
<strong id="cubeResultWhiteName">@resultView.WhiteName</strong>
<span id="cubeResultWhiteTime">@resultView.WhiteTime</span>
<span id="cubeResultWhiteDetail">@resultView.WhiteDetail</span>
<span id="cubeResultWhiteClock">@resultView.WhiteClock</span>
</article>
<article class="cube-result-player-card">
<span class="seat-tag dark-seat">Noir</span>
<strong id="cubeResultBlackName">@resultView.BlackName</strong>
<span id="cubeResultBlackTime">@resultView.BlackTime</span>
<span id="cubeResultBlackDetail">@resultView.BlackDetail</span>
<span id="cubeResultBlackClock">@resultView.BlackClock</span>
</article>
</div>
<div class="modal-actions">
<button class="button primary" id="cubeResultActionButton" type="button" @onclick="ApplyResultAsync">
@resultView.ActionLabel
</button>
<button class="button ghost" id="cubeResultDismissButton" type="button" @onclick="CloseResultModal">
Revenir a la phase cube
</button>
</div>
</div>
}
</section>
}
@code {
private readonly Dictionary<string, CubeHoldState> _holdStates = new(StringComparer.Ordinal)
{
[MatchEngine.ColorWhite] = new(),
[MatchEngine.ColorBlack] = new(),
};
private CancellationTokenSource? _tickerCancellation;
private bool _ready;
private bool ShowHelpModal;
private bool ShowResultModal;
private string? _resultModalKey;
private long _knownCollaborativeRevision;
private MatchState? Match => Store.Current;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
await Store.EnsureLoadedAsync();
if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId))
{
await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId);
await ApplyCollaborativeSyncAsync();
}
_ready = true;
if (Match is null)
{
Navigation.NavigateTo("/application.html", replace: true);
return;
}
if (string.IsNullOrEmpty(Match.Result) && Match.Phase != MatchEngine.PhaseCube)
{
Navigation.NavigateTo("/chrono.html", replace: true);
return;
}
_tickerCancellation = new CancellationTokenSource();
_ = RunTickerAsync(_tickerCancellation.Token);
await TryPlayPendingAlertAsync();
UpdateResultModalState();
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
if (_tickerCancellation is not null)
{
_tickerCancellation.Cancel();
_tickerCancellation.Dispose();
}
Realtime.Changed -= HandleRealtimeChanged;
await Store.FlushIfDueAsync(0);
}
private void HandleRealtimeChanged()
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
private async Task HandleRealtimeChangedAsync()
{
await ApplyCollaborativeSyncAsync();
UpdateResultModalState();
StateHasChanged();
}
private async Task RunTickerAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
var match = Match;
if (match is null)
{
continue;
}
if (string.IsNullOrEmpty(match.Result) && match.Phase != MatchEngine.PhaseCube)
{
await Store.SaveAsync();
await InvokeAsync(() => Navigation.NavigateTo("/chrono.html", replace: true));
return;
}
UpdateResultModalState();
await Store.FlushIfDueAsync();
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
}
private void OpenHelpModal()
=> ShowHelpModal = true;
private void CloseHelpModal()
=> ShowHelpModal = false;
private void CloseResultModal()
=> ShowResultModal = false;
private async Task HandlePrimaryActionAsync()
{
if (Match is null)
{
return;
}
if (!string.IsNullOrEmpty(Match.Result))
{
Navigation.NavigateTo("/application.html");
return;
}
if (Match.Phase != MatchEngine.PhaseCube)
{
Navigation.NavigateTo("/chrono.html", replace: true);
return;
}
if (BuildResultView() is not null)
{
ShowResultModal = true;
}
}
private async Task HandleCubePointerDownAsync(string color)
{
var match = Match;
if (match is null)
{
return;
}
await Browser.PrimeAudioAsync();
await TryPlayPendingAlertAsync();
if (!string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseCube)
{
return;
}
if ((color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black) is not null)
{
return;
}
var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black;
if (playerState.Running)
{
return;
}
_holdStates[color].Armed = true;
_holdStates[color].StartedAt = MatchEngine.NowUnixMs();
}
private async Task HandleCubePointerUpAsync(string color)
{
var match = Match;
if (match is null)
{
return;
}
await Browser.PrimeAudioAsync();
await TryPlayPendingAlertAsync();
if (!string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseCube)
{
CancelCubeHold(color);
return;
}
if ((color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black) is not null)
{
CancelCubeHold(color);
return;
}
var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black;
if (playerState.Running)
{
MatchEngine.CaptureCubeTime(match, color);
CancelCubeHold(color);
await PersistCubeAsync();
return;
}
var ready = IsHoldReady(color);
CancelCubeHold(color);
if (!ready)
{
return;
}
MatchEngine.StartCubeTimer(match, color);
await PersistCubeAsync();
}
private void CancelCubeHold(string color)
{
_holdStates[color].Armed = false;
_holdStates[color].StartedAt = 0;
}
private async Task ApplyResultAsync()
{
var match = Match;
var resultView = BuildResultView();
if (match is null || resultView is null)
{
ShowResultModal = false;
return;
}
if (resultView.ReplayRequired)
{
ShowResultModal = false;
_resultModalKey = null;
MatchEngine.ReplayCubePhase(match);
await PersistCubeAsync();
await TryPlayPendingAlertAsync();
return;
}
ShowResultModal = false;
MatchEngine.ApplyCubeOutcome(match);
Store.MarkDirty();
await Store.SaveAsync();
await Realtime.PublishMatchStateAsync(match, "/chrono.html");
Navigation.NavigateTo("/chrono.html");
}
private async Task ReplayCubeAsync()
{
var match = Match;
if (match is null || match.Phase != MatchEngine.PhaseCube)
{
return;
}
ShowResultModal = false;
_resultModalKey = null;
MatchEngine.ReplayCubePhase(match);
await PersistCubeAsync();
await TryPlayPendingAlertAsync();
}
private async Task ResetMatchAsync()
{
var sessionId = Match?.CollaborationSessionId;
await Store.ClearAsync();
if (!string.IsNullOrWhiteSpace(sessionId))
{
await Realtime.PublishMatchStateAsync(null, "/application.html");
}
Navigation.NavigateTo("/application.html", replace: true);
}
private async Task PersistCubeAsync()
{
Store.MarkDirty();
await Store.SaveAsync();
await Realtime.PublishMatchStateAsync(Match, "/cube.html");
UpdateResultModalState();
StateHasChanged();
}
private async Task TryPlayPendingAlertAsync()
{
var match = Match;
if (match is null || !match.Cube.PhaseAlertPending)
{
return;
}
if (!await Browser.PlayCubePhaseAlertAsync())
{
return;
}
match.Cube.PhaseAlertPending = false;
Store.MarkDirty();
await Store.SaveAsync();
}
private CubeSummaryView BuildSummary()
{
var match = Match!;
var blockHeading = MatchEngine.FormatBlockHeading(match, match.BlockNumber);
var subtitle = $"{blockHeading} - {MatchEngine.Modes[match.Config.Mode].Label} - {MatchEngine.RenderModeContext(match)}";
if (!string.IsNullOrEmpty(match.Result))
{
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Resultat",
MatchEngine.ResultText(match),
"Termine",
MatchEngine.ResultText(match),
"Retournez a la configuration pour relancer une rencontre.",
"Retour a l'accueil",
"Le match est termine.",
false);
}
if (match.Cube.Times.White is not null && match.Cube.Times.Black is not null && match.Config.Mode == MatchEngine.ModeTwice && match.Cube.Times.White == match.Cube.Times.Black)
{
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Decision",
"Egalite parfaite",
"Reglement",
"Rejouer la phase cube",
"Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite.",
"Voir le resume du cube",
"Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite.",
false);
}
if (match.Cube.Times.White is not null && match.Cube.Times.Black is not null)
{
var preview = match.Config.Mode == MatchEngine.ModeTime
? MatchEngine.GetTimeAdjustmentPreview(match, match.Cube.Times.White.Value, match.Cube.Times.Black.Value)
: null;
return preview is null
? new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Decision",
"Phase cube complete",
"Suite",
"Ouvrir la page chrono",
"Appliquer le resultat du cube pour preparer le Block suivant.",
"Voir le resume du cube",
"Appliquer le resultat du cube pour preparer le Block suivant.",
false)
: new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Vainqueur cube",
preview.Winner is not null ? MatchEngine.PlayerName(match, preview.Winner) : "Egalite",
"Impact chrono",
preview.BlockType == "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer",
$"Blanc {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)} -> {MatchEngine.FormatSignedClock(preview.WhiteAfter)}. Noir {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)} -> {MatchEngine.FormatSignedClock(preview.BlackAfter)}.",
"Voir le resume du cube",
$"Blanc {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)} -> {MatchEngine.FormatSignedClock(preview.WhiteAfter)}. Noir {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)} -> {MatchEngine.FormatSignedClock(preview.BlackAfter)}.",
false);
}
if (match.Cube.Running)
{
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Etat",
"Chronos lances",
"Arrets",
"Chaque joueur se chronometre",
"Chaque joueur demarre en relachant sa zone, puis retape sa zone une fois le cube termine.",
"Attendre les deux temps",
"Chaque joueur demarre en relachant sa zone, puis retape sa zone une fois le cube termine.",
true);
}
if (match.Cube.Times.White is not null || match.Cube.Times.Black is not null)
{
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Etat",
"Un temps saisi",
"Suite",
"Attendre l'autre joueur",
"Le deuxieme joueur peut encore maintenir puis relacher sa zone pour demarrer son propre chrono.",
"Attendre le deuxieme temps",
"Le deuxieme joueur peut encore maintenir puis relacher sa zone pour demarrer son propre chrono.",
true);
}
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Etat",
"Pret",
"Depart libre",
"Chaque joueur lance son chrono",
"Au debut de sa resolution, chaque joueur maintient sa grande zone puis la relache pour demarrer son propre chrono.",
"En attente des joueurs",
"Au debut de sa resolution, chaque joueur maintient sa grande zone puis la relache pour demarrer son propre chrono.",
true);
}
private CubeZoneView BuildZone(string color)
{
var match = Match!;
var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black;
var time = color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black;
var hold = _holdStates[color];
var holdReady = IsHoldReady(color);
var holdArmed = hold.Armed && !holdReady && !playerState.Running && time is null && match.Phase == MatchEngine.PhaseCube && string.IsNullOrEmpty(match.Result);
var holdProgress = hold.Armed && time is null && !playerState.Running
? Math.Min((MatchEngine.NowUnixMs() - hold.StartedAt) / (double)MatchEngine.CubeStartHoldMs, 1d)
: 0d;
if (!string.IsNullOrEmpty(match.Result))
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
MatchEngine.ResultText(match),
"Le match est termine.",
true,
false,
false,
"--cube-hold-progress: 0");
}
if (match.Phase != MatchEngine.PhaseCube)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Retour chrono",
"La page cube est terminee.",
true,
false,
false,
"--cube-hold-progress: 0");
}
if (time is not null)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Temps enregistre",
"Ce joueur a deja termine son cube.",
true,
false,
false,
"--cube-hold-progress: 1");
}
if (playerState.Running)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"J'ai fini le cube",
"Tape au moment exact ou le cube est resolu.",
false,
false,
false,
"--cube-hold-progress: 1");
}
if (holdReady)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Relachez pour demarrer",
"Le chrono partira des que vous levez le doigt.",
false,
true,
true,
"--cube-hold-progress: 1");
}
if (holdArmed)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Maintenez 2 s...",
"Gardez le doigt pose 2 secondes, jusqu'a la fin de la barre.",
false,
true,
false,
$"--cube-hold-progress: {holdProgress.ToString("0.###", CultureInfo.InvariantCulture)}");
}
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Maintenir 2 s pour demarrer",
"Maintenez la grande zone 2 secondes, puis relachez pour lancer votre chrono.",
false,
false,
false,
"--cube-hold-progress: 0");
}
private CubeResultView? BuildResultView()
{
var match = Match;
if (match is null || match.Phase != MatchEngine.PhaseCube)
{
return null;
}
var white = match.Cube.Times.White;
var black = match.Cube.Times.Black;
if (white is null || black is null)
{
return null;
}
var whiteName = MatchEngine.PlayerName(match, MatchEngine.ColorWhite);
var blackName = MatchEngine.PlayerName(match, MatchEngine.ColorBlack);
if (match.Config.Mode == MatchEngine.ModeTime)
{
var preview = MatchEngine.GetTimeAdjustmentPreview(match, white.Value, black.Value);
if (preview is null)
{
return null;
}
return new CubeResultView(
$"time:{match.BlockNumber}:{match.Cube.Round}:{white}:{black}",
"Resume du cube",
preview.Winner is not null ? MatchEngine.PlayerName(match, preview.Winner) : "Egalite",
preview.BlockType == "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer",
"Validez ce resume pour appliquer les impacts chrono puis revenir a la page chrono.",
"Appliquer et ouvrir la page chrono",
whiteName,
blackName,
$"Temps cube {MatchEngine.FormatStopwatch(white.Value)}",
$"Temps cube {MatchEngine.FormatStopwatch(black.Value)}",
$"Impact chrono {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)}",
$"Impact chrono {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)}",
$"Chrono apres {MatchEngine.FormatSignedClock(preview.WhiteAfter)}",
$"Chrono apres {MatchEngine.FormatSignedClock(preview.BlackAfter)}",
false);
}
var winner = white < black ? MatchEngine.ColorWhite : black < white ? MatchEngine.ColorBlack : null;
var tie = winner is null;
return new CubeResultView(
$"twice:{match.BlockNumber}:{match.Cube.Round}:{white}:{black}",
tie ? "Egalite parfaite" : "Resume du cube",
winner is not null ? MatchEngine.PlayerName(match, winner) : "Egalite parfaite",
tie ? "Rejouer la phase cube" : $"{MatchEngine.PlayerName(match, winner!)} ouvrira le Block suivant",
tie ? "Le reglement Twice impose de rejouer immediatement la phase cube." : "Validez ce resultat pour preparer le Block suivant.",
tie ? "Rejouer la phase cube" : "Appliquer et ouvrir la page chrono",
whiteName,
blackName,
$"Temps cube {MatchEngine.FormatStopwatch(white.Value)}",
$"Temps cube {MatchEngine.FormatStopwatch(black.Value)}",
winner == MatchEngine.ColorWhite ? "Gagne la phase cube" : tie ? "Egalite parfaite" : "Ne gagne pas la phase cube",
winner == MatchEngine.ColorBlack ? "Gagne la phase cube" : tie ? "Egalite parfaite" : "Ne gagne pas la phase cube",
"Aucun impact chrono en mode Twice",
"Aucun impact chrono en mode Twice",
tie);
}
private void UpdateResultModalState()
{
var resultView = BuildResultView();
if (resultView is null)
{
ShowResultModal = false;
_resultModalKey = null;
return;
}
if (_resultModalKey != resultView.Key)
{
_resultModalKey = resultView.Key;
ShowResultModal = true;
}
}
private bool IsHoldReady(string color)
=> _holdStates[color].Armed && MatchEngine.NowUnixMs() - _holdStates[color].StartedAt >= MatchEngine.CubeStartHoldMs;
private static string BoolString(bool value)
=> value ? "true" : "false";
private async Task ApplyCollaborativeSyncAsync()
{
var snapshot = Realtime.CollaborativeSnapshot;
if (snapshot is null || snapshot.Revision <= _knownCollaborativeRevision)
{
return;
}
_knownCollaborativeRevision = snapshot.Revision;
Store.ReplaceCurrent(snapshot.Match);
await Store.SaveAsync();
var route = NormalizeRoute(snapshot.Route);
if (!string.Equals(route, "/cube.html", StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo(route, replace: true);
}
}
private static string NormalizeRoute(string? route)
{
var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim();
return normalized.StartsWith('/') ? normalized : $"/{normalized}";
}
private sealed class CubeHoldState
{
public bool Armed { get; set; }
public long StartedAt { get; set; }
}
private sealed record CubeSummaryView(
string Title,
string Subtitle,
string CenterLabel,
string CenterValue,
string SpineLabel,
string SpineHeadline,
string SpineText,
string PrimaryButtonText,
string HelpStatus,
bool PrimaryDisabled);
private sealed record CubeZoneView(
string Name,
string ResultText,
string MetaText,
string ButtonText,
string Hint,
bool Disabled,
bool HoldArmed,
bool HoldReady,
string ProgressStyle);
private sealed record CubeResultView(
string Key,
string Title,
string Winner,
string Outcome,
string Summary,
string ActionLabel,
string WhiteName,
string BlackName,
string WhiteTime,
string BlackTime,
string WhiteDetail,
string BlackDetail,
string WhiteClock,
string BlackClock,
bool ReplayRequired);
}

View File

@@ -0,0 +1,239 @@
@page "/"
@page "/index.html"
<PageTitle>ChessCubing Arena | Accueil</PageTitle>
<PageBody BodyClass="home-body" />
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="rules-shell">
<header class="hero hero-home">
<div class="hero-copy">
<a class="logo-lockup" href="index.html" aria-label="Accueil ChessCubing">
<img class="hero-logo-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="hero-logo" src="transparent.png" alt="Logo ChessCubing" />
</a>
<p class="eyebrow">Association ChessCubing</p>
<h1>Les echecs rencontrent le Rubik's Cube</h1>
<p class="lead">
ChessCubing propose un jeu hybride simple a comprendre, intense a
vivre et tres plaisant a regarder. On joue une partie d'echecs, on
passe par une phase cube obligatoire, puis la partie repart avec un
nouveau rythme.
</p>
<div class="hero-actions">
<a class="button primary" href="application.html">Ouvrir l'application</a>
<a class="button secondary" href="reglement.html">Lire le reglement</a>
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
<a class="button ghost" href="/brice/">Ouvrir l'appli de Brice</a>
</div>
</div>
<aside class="hero-preview">
<div class="preview-card">
<p class="micro-label">Le principe</p>
<ol class="phase-list">
<li>Deux joueurs disputent une partie d'echecs courte et nerveuse.</li>
<li>Une phase cube coupe le rythme et relance la tension.</li>
<li>Le resultat du cube influence immediatement la suite du match.</li>
</ol>
</div>
<div class="preview-banner">
<span class="mini-chip">Decouverte rapide</span>
<strong>On comprend en quelques minutes</strong>
<p>
Le format donne envie de jouer meme quand on decouvre seulement
l'un des deux univers. On peut venir pour les echecs, pour le
cube, ou simplement pour l'energie du duel.
</p>
</div>
</aside>
</header>
<main class="rules-grid">
<section class="panel panel-wide">
<div class="section-heading">
<div>
<p class="eyebrow">L'association</p>
<h2>Une passerelle entre deux passions</h2>
</div>
<p class="section-copy">
L'association ChessCubing veut creer des moments de rencontre, de
decouverte et de jeu partage autour d'un format hybride.
</p>
</div>
<div class="home-story-grid">
<article class="stage-card">
<span class="micro-label">Partager</span>
<strong>Faire decouvrir un format original</strong>
<p>
L'idee est simple : reunir des profils differents autour d'un
jeu lisible, vivant et immediatement intriguant.
</p>
</article>
<article class="stage-card">
<span class="micro-label">Rassembler</span>
<strong>Creer un terrain commun</strong>
<p>
Joueurs d'echecs, cubers, curieux, familles ou clubs peuvent se
retrouver dans une experience facile a lancer et a commenter.
</p>
</article>
<article class="stage-card">
<span class="micro-label">Animer</span>
<strong>Donner envie de revenir jouer</strong>
<p>
Le melange de reflexion, de vitesse et de rebondissements donne
au format un cote spectaculaire qui marche tres bien en
initiation comme en evenement.
</p>
</article>
</div>
</section>
<section class="panel panel-half">
<div class="section-heading">
<div>
<p class="eyebrow">Le jeu en simple</p>
<h2>Comment se passe une partie</h2>
</div>
</div>
<div class="home-mini-grid">
<article class="mini-panel">
<span class="micro-label">1</span>
<strong>On joue une partie d'echecs</strong>
<p>
La partie avance par sequences courtes, ce qui garde un tres bon
rythme et rend l'action facile a suivre.
</p>
</article>
<article class="mini-panel">
<span class="micro-label">2</span>
<strong>On resout un cube</strong>
<p>
Les deux joueurs enchainent avec une phase cube obligatoire qui
sert de relance, de pression et de bascule.
</p>
</article>
<article class="mini-panel">
<span class="micro-label">3</span>
<strong>Le match repart</strong>
<p>
Selon le mode choisi, la phase cube donne l'initiative ou agit
sur les chronos. Le duel ne retombe jamais.
</p>
</article>
</div>
</section>
<section class="panel panel-half">
<div class="section-heading">
<div>
<p class="eyebrow">Pourquoi ca plait</p>
<h2>Un format qui donne envie d'essayer</h2>
</div>
</div>
<div class="home-mini-grid two-columns">
<article class="mini-panel">
<strong>Tres lisible</strong>
<p>On comprend vite quand la pression monte et pourquoi le cube change tout.</p>
</article>
<article class="mini-panel">
<strong>Toujours relance</strong>
<p>Chaque phase cube remet du suspense dans la partie et ouvre de nouvelles options.</p>
</article>
<article class="mini-panel">
<strong>Convivial</strong>
<p>Le format marche bien en initiation, en animation de club ou en demonstration publique.</p>
</article>
<article class="mini-panel">
<strong>Memorable</strong>
<p>On ressort d'une partie avec des coups, des temps et des retournements dont on se souvient.</p>
</article>
</div>
</section>
<section class="panel panel-wide">
<div class="section-heading">
<div>
<p class="eyebrow">Deux formats</p>
<h2>Deux manieres de vivre le duel</h2>
</div>
<p class="section-copy">
Le reglement officiel propose deux lectures du meme concept, l'une
plus orientee initiative, l'autre plus orientee gestion du temps.
</p>
</div>
<div class="rules-compare">
<article class="format-card twice-card">
<div class="format-head">
<span class="mini-chip">ChessCubing Twice</span>
<h3>Le cube donne l'elan</h3>
<p>
Le joueur le plus rapide sur la phase cube prend le depart de
la partie suivante et peut meme obtenir un double coup dans
certaines situations.
</p>
</div>
<div class="format-badges">
<span>Mode nerveux</span>
<span>Initiative forte</span>
<span>Effet immediat</span>
</div>
</article>
<article class="format-card time-card">
<div class="format-head">
<span class="mini-chip">ChessCubing Time</span>
<h3>Le cube agit sur les chronos</h3>
<p>
Ici, on garde le trait mais la performance sur le cube retire
ou ajoute du temps selon l'alternance des blocs.
</p>
</div>
<div class="format-badges">
<span>Gestion de temps</span>
<span>Blocs - et +</span>
<span>Suspense permanent</span>
</div>
</article>
</div>
</section>
<section class="panel panel-wide cta-panel">
<div class="section-heading">
<div>
<p class="eyebrow">Envie de tester</p>
<h2>Choisis ton entree</h2>
</div>
<p class="section-copy">
Tu peux decouvrir le jeu tranquillement avec la page reglement ou
aller directement vers l'application officielle de match.
</p>
</div>
<div class="source-grid">
<a class="source-card" href="application.html">
<span class="micro-label">Application</span>
<strong>Ouvrir la partie arbitrage</strong>
<p>Configurer une rencontre, lancer le chrono et enchainer avec la phase cube.</p>
</a>
<a class="source-card" href="reglement.html">
<span class="micro-label">Reglement</span>
<strong>Comprendre les formats officiels</strong>
<p>Retrouver une presentation claire du Twice, du Time et des regles de match.</p>
</a>
<a class="source-card" href="ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" target="_blank">
<span class="micro-label">PDF officiel</span>
<strong>Lire le texte source</strong>
<p>Consulter directement le document officiel si tu veux tous les details.</p>
</a>
</div>
</section>
</main>
</div>

View File

@@ -0,0 +1,39 @@
@page "/not-found"
<PageTitle>ChessCubing Arena | Page introuvable</PageTitle>
<PageBody BodyClass="home-body" />
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="rules-shell">
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
<div class="section-heading">
<div>
<p class="eyebrow">Navigation</p>
<h1>Page introuvable</h1>
</div>
<p class="section-copy">
Le lien demande n'existe pas ou n'est plus expose par l'application Blazor.
</p>
</div>
<div class="source-grid">
<a class="source-card" href="index.html">
<span class="micro-label">Accueil</span>
<strong>Retourner au site</strong>
<p>Revenir a la page d'accueil de ChessCubing Arena.</p>
</a>
<a class="source-card" href="application.html">
<span class="micro-label">Application</span>
<strong>Ouvrir l'arbitrage</strong>
<p>Configurer un match et basculer vers les phases chrono et cube.</p>
</a>
<a class="source-card" href="reglement.html">
<span class="micro-label">Reglement</span>
<strong>Lire les formats officiels</strong>
<p>Retrouver la synthese des reglements Twice et Time.</p>
</a>
</div>
</section>
</div>

View File

@@ -0,0 +1,344 @@
@page "/reglement"
@page "/reglement.html"
<PageTitle>ChessCubing Arena | Reglement officiel</PageTitle>
<PageBody BodyClass="rules-body" />
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="rules-shell">
<header class="hero hero-rules">
<div class="hero-copy">
<a class="logo-lockup" href="index.html" aria-label="Accueil ChessCubing">
<img class="hero-logo-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="hero-logo" src="transparent.png" alt="Logo ChessCubing" />
</a>
<p class="eyebrow">Referentiel officiel</p>
<h1>Reglement ChessCubing</h1>
<p class="lead">
Cette page reprend les regles officielles en vigueur du
ChessCubing Twice et du ChessCubing Time pour offrir une lecture
rapide, claire et directement exploitable en club, en demonstration
ou en arbitrage.
</p>
<div class="hero-pills">
<span>Twice V2</span>
<span>Time V1</span>
<span>Entree en vigueur le 4 fevrier 2026</span>
<span>Fin uniquement par mat ou abandon</span>
</div>
<div class="hero-actions">
<a class="button primary" href="application.html">Ouvrir l'application</a>
<a class="button secondary" href="index.html">Retour a l'accueil</a>
</div>
</div>
<aside class="hero-preview">
<div class="preview-card">
<p class="micro-label">Reperes rapides</p>
<div class="rule-metrics">
<article class="metric-chip">
<span>Partie</span>
<strong>180 s</strong>
</article>
<article class="metric-chip">
<span>Cube</span>
<strong>Obligatoire</strong>
</article>
<article class="metric-chip">
<span>Quotas</span>
<strong>6 / 8 / 10</strong>
</article>
<article class="metric-chip">
<span>Chrono cube</span>
<strong>Cap 120 s en Time</strong>
</article>
</div>
</div>
<div class="preview-banner">
<span class="mini-chip">Deux formats</span>
<strong>Meme structure, effets differents</strong>
<p>
Le mode Twice donne l'initiative au gagnant du cube et peut
declencher un double coup. Le mode Time ajuste les chronos sur
des blocs - et des blocs +, sans priorite ni double coup.
</p>
</div>
</aside>
</header>
<main class="rules-grid">
<section class="panel panel-wide">
<div class="section-heading">
<div>
<p class="eyebrow">Vue d'ensemble</p>
<h2>Le match en 4 temps</h2>
</div>
<p class="section-copy">
Les deux reglements partagent la meme colonne vertebrale : un
match en parties successives, interrompues par une phase cube
obligatoire.
</p>
</div>
<div class="rules-stage-grid">
<article class="stage-card">
<span class="micro-label">1. Avant le match</span>
<strong>Installation et tirage</strong>
<p>
L'arbitre controle le materiel, les cubes, les caches, les
melanges, puis le tirage au sort qui attribue Blancs ou Noirs.
</p>
</article>
<article class="stage-card">
<span class="micro-label">2. Partie d'echecs</span>
<strong>180 secondes de jeu</strong>
<p>
Chaque partie comporte une phase d'echecs limitee par une duree
fixe et par un quota de coups selon le format FAST, FREEZE ou
MASTERS.
</p>
</article>
<article class="stage-card">
<span class="micro-label">3. Phase cube</span>
<strong>Un cube identique par joueur</strong>
<p>
L'application designe le numero du cube, les deux joueurs
resolvent le meme melange, et le resultat influe ensuite selon
le mode choisi.
</p>
</article>
<article class="stage-card">
<span class="micro-label">4. Fin de partie</span>
<strong>Jamais au temps</strong>
<p>
La partie se termine uniquement par echec et mat ou abandon,
jamais par simple chute au temps ou depassement d'une partie.
</p>
</article>
</div>
</section>
<section class="panel panel-half">
<div class="section-heading">
<div>
<p class="eyebrow">Materiel commun</p>
<h2>Base officielle</h2>
</div>
</div>
<ul class="rule-list">
<li>Un echiquier et un jeu de pieces reglementaires.</li>
<li>Huit Rubik's Cubes 3x3, soit quatre par joueur.</li>
<li>Des caches opaques numerotes de 1 a 4.</li>
<li>Des melanges strictement identiques pour chaque numero.</li>
<li>L'application officielle ChessCubing.</li>
<li>Un arbitre pour piloter le match et les transitions.</li>
</ul>
</section>
<section class="panel panel-half">
<div class="section-heading">
<div>
<p class="eyebrow">Arbitrage</p>
<h2>Check-list terrain</h2>
</div>
</div>
<ul class="rule-list">
<li>Verifier la presence des huit cubes et des caches numerotes.</li>
<li>Confirmer des melanges identiques sous chaque numero.</li>
<li>Preparer l'echiquier et la variante dans l'application.</li>
<li>Controler le tirage au sort avant la premiere partie.</li>
<li>Declencher chaque phase cube au bon moment.</li>
<li>Surveiller le respect du plafond de 120 s en mode Time.</li>
</ul>
</section>
<section class="panel panel-wide" id="formats">
<div class="section-heading">
<div>
<p class="eyebrow">Formats officiels</p>
<h2>Twice et Time, cote a cote</h2>
</div>
<p class="section-copy">
Les deux formats partagent les parties et la phase cube, mais leur
logique d'avantage differe completement.
</p>
</div>
<div class="rules-compare">
<article class="format-card twice-card">
<div class="format-head">
<span class="mini-chip">Version V2</span>
<h3>ChessCubing Twice</h3>
<p>
Le gagnant du cube obtient l'initiative sur la partie suivante,
avec une regle de double coup encadree.
</p>
</div>
<div class="format-badges">
<span>Partie : 180 s</span>
<span>Temps par coup : 20 s max</span>
<span>FAST / FREEZE / MASTERS : 6 / 8 / 10</span>
</div>
<div class="format-section">
<h4>Debut et fin des parties</h4>
<ul class="rule-list compact">
<li>Les Blancs commencent la partie 1.</li>
<li>Aucun double coup n'est possible a la partie 1.</li>
<li>Une partie s'arrete a 180 s ou quand les deux quotas sont atteints.</li>
<li>Il est interdit de finir une partie avec un roi en echec.</li>
<li>Si le dernier coup donne echec, les coups necessaires pour parer sont joues hors quota.</li>
</ul>
</div>
<div class="format-section">
<h4>Phase cube</h4>
<ul class="rule-list compact">
<li>Le numero du cube est designe par l'application.</li>
<li>Les deux joueurs recoivent un melange identique.</li>
<li>Le joueur le plus rapide gagne la phase cube.</li>
<li>En cas d'egalite parfaite, la phase cube est rejouee.</li>
<li>Le gagnant du cube commence la partie suivante.</li>
</ul>
</div>
<div class="callout">
<span class="micro-label">Double coup V2</span>
<strong>Condition stricte</strong>
<p>
Le gagnant du cube ne doit pas avoir joue le dernier coup de
la partie precedente. Le premier coup est gratuit, non compte,
peut capturer mais ne peut pas donner echec. Le second compte
comme premier coup de la partie, peut donner echec, mais ne peut
capturer qu'un pion ou une piece mineure.
</p>
</div>
<div class="format-section">
<h4>Temps par coup et fin de partie</h4>
<ul class="rule-list compact">
<li>Chaque coup doit etre joue en 20 secondes maximum.</li>
<li>En depassement, le coup est perdu et compte dans le quota.</li>
<li>Sur le premier coup d'un double coup, le depassement annule l'avantage.</li>
<li>Sur le second coup d'un double coup, le coup est perdu et comptabilise.</li>
<li>La partie se termine uniquement par mat ou abandon.</li>
</ul>
</div>
</article>
<article class="format-card time-card">
<div class="format-head">
<span class="mini-chip">Version V1</span>
<h3>ChessCubing Time</h3>
<p>
Ici, la phase cube n'offre pas l'initiative mais modifie les
chronos selon une alternance bloc - puis bloc +.
</p>
</div>
<div class="format-badges">
<span>Temps initial : 10 min / joueur</span>
<span>Block : 180 s</span>
<span>Cap cube pris en compte : 120 s</span>
</div>
<div class="format-section">
<h4>Structure temporelle</h4>
<ul class="rule-list compact">
<li>La structure des Blocks est identique a celle du Twice.</li>
<li>Les quotas de coups restent les memes : 6, 8 ou 10.</li>
<li>Chaque Block est suivi d'une phase cube obligatoire.</li>
<li>Le trait est conserve apres la phase cube.</li>
<li>Aucun systeme de priorite ou de double coup n'existe.</li>
</ul>
</div>
<div class="split-callouts">
<article class="split-card minus-card">
<span class="micro-label">Block impair</span>
<strong>Bloc -</strong>
<p>
Le temps de resolution du cube est retire du chrono du
joueur concerne, avec un plafond de 120 secondes.
</p>
</article>
<article class="split-card plus-card">
<span class="micro-label">Block pair</span>
<strong>Bloc +</strong>
<p>
Le temps de resolution du cube est ajoute au chrono adverse,
lui aussi plafonne a 120 secondes.
</p>
</article>
</div>
<div class="format-section">
<h4>Exemples officiels</h4>
<ul class="rule-list compact">
<li>Bloc - : 35 s retire 35 s a son propre chrono.</li>
<li>Bloc - : 110 s retire 110 s a son propre chrono.</li>
<li>Bloc + : 25 s ajoute 25 s au chrono adverse.</li>
<li>Bloc + : 150 s ajoute 120 s au chrono adverse.</li>
</ul>
</div>
<div class="callout">
<span class="micro-label">Reprise du jeu</span>
<strong>Pas de coup pendant le cube</strong>
<p>
Aucun coup ne peut etre joue pendant la phase cube. Des que
les deux resolutions sont terminees, les chronos sont ajustes
et la partie reprend immediatement.
</p>
</div>
<div class="format-section">
<h4>Fin de partie et vigilance</h4>
<ul class="rule-list compact">
<li>La partie s'arrete uniquement par mat ou abandon volontaire.</li>
<li>L'arbitre surveille le chronometrage exact et le plafond de 120 s.</li>
<li>L'absence de priorite et de double coup fait partie des points cles du mode.</li>
</ul>
</div>
</article>
</div>
</section>
<section class="panel panel-wide">
<div class="section-heading">
<div>
<p class="eyebrow">Sources</p>
<h2>Documents officiels</h2>
</div>
<p class="section-copy">
Cette synthese reprend les versions officielles actuellement
embarquees dans l'application.
</p>
</div>
<div class="source-grid">
<a class="source-card" href="ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" target="_blank">
<span class="micro-label">PDF officiel</span>
<strong>ChessCubing Twice V2</strong>
<p>Version entree en vigueur le 4 fevrier 2026.</p>
</a>
<a class="source-card" href="ChessCubing_Time_Reglement_Officiel_V1-1.pdf" target="_blank">
<span class="micro-label">PDF officiel</span>
<strong>ChessCubing Time V1</strong>
<p>Version entree en vigueur le 4 fevrier 2026.</p>
</a>
<a class="source-card" href="application.html">
<span class="micro-label">Application</span>
<strong>Retour a ChessCubing Arena</strong>
<p>Revenir a la configuration du match et a l'arbitrage mobile.</p>
</a>
</div>
</section>
</main>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
using ChessCubing.App;
using ChessCubing.App.Services;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Components.Authorization;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AppAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AppAuthenticationStateProvider>());
builder.Services.AddScoped<BrowserBridge>();
builder.Services.AddScoped<UserSession>();
builder.Services.AddScoped<MatchStore>();
builder.Services.AddScoped<SocialRealtimeService>();
builder.Services.AddScoped<MatchStatsService>();
await builder.Build().RunAsync();

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5263",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,101 @@
using System.Net.Http.Json;
using System.Security.Claims;
using ChessCubing.App.Models.Auth;
using Microsoft.AspNetCore.Components.Authorization;
namespace ChessCubing.App.Services;
public sealed class AppAuthenticationStateProvider(HttpClient httpClient) : AuthenticationStateProvider
{
private static readonly AuthenticationState AnonymousState = new(new ClaimsPrincipal(new ClaimsIdentity()));
private AuthenticationState? _cachedState;
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (_cachedState is not null)
{
return _cachedState;
}
_cachedState = await LoadStateAsync();
return _cachedState;
}
public async Task RefreshAsync()
{
_cachedState = await LoadStateAsync();
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
}
public void SetAuthenticated(AuthSessionResponse session)
{
_cachedState = new AuthenticationState(BuildPrincipal(session));
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
}
public void SetAnonymous()
{
_cachedState = AnonymousState;
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
}
private async Task<AuthenticationState> LoadStateAsync()
{
try
{
var session = await httpClient.GetFromJsonAsync<AuthSessionResponse>("api/auth/session");
if (session is null || !session.IsAuthenticated)
{
return AnonymousState;
}
return new AuthenticationState(BuildPrincipal(session));
}
catch
{
return AnonymousState;
}
}
private static ClaimsPrincipal BuildPrincipal(AuthSessionResponse session)
{
if (!session.IsAuthenticated)
{
return AnonymousState.User;
}
var claims = new List<Claim>();
if (!string.IsNullOrWhiteSpace(session.Subject))
{
claims.Add(new Claim("sub", session.Subject));
claims.Add(new Claim(ClaimTypes.NameIdentifier, session.Subject));
}
if (!string.IsNullOrWhiteSpace(session.Username))
{
claims.Add(new Claim("preferred_username", session.Username));
claims.Add(new Claim(ClaimTypes.Name, session.Username));
}
if (!string.IsNullOrWhiteSpace(session.Name))
{
claims.Add(new Claim("name", session.Name));
}
if (!string.IsNullOrWhiteSpace(session.Email))
{
claims.Add(new Claim("email", session.Email));
claims.Add(new Claim(ClaimTypes.Email, session.Email));
}
foreach (var role in session.Roles.Where(role => !string.IsNullOrWhiteSpace(role)))
{
claims.Add(new Claim("role", role));
claims.Add(new Claim(ClaimTypes.Role, role));
}
return new ClaimsPrincipal(new ClaimsIdentity(claims, "ChessCubingCookie"));
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.JSInterop;
namespace ChessCubing.App.Services;
public sealed class BrowserBridge(IJSRuntime jsRuntime)
{
public ValueTask StartViewportAsync()
=> jsRuntime.InvokeVoidAsync("chesscubingViewport.start");
public ValueTask SetBodyStateAsync(string? page, string? bodyClass)
=> jsRuntime.InvokeVoidAsync("chesscubingPage.setBodyState", page, bodyClass ?? string.Empty);
public ValueTask SyncMenuAsync()
=> jsRuntime.InvokeVoidAsync("chesscubingMenu.sync");
public ValueTask<string?> ReadMatchJsonAsync(string storageKey, string windowNameKey)
=> jsRuntime.InvokeAsync<string?>("chesscubingStorage.getMatchState", storageKey, windowNameKey);
public ValueTask WriteMatchJsonAsync(string storageKey, string windowNameKey, string json)
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.setMatchState", storageKey, windowNameKey, json);
public ValueTask ClearMatchAsync(string storageKey, string windowNameKey)
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.clearMatchState", storageKey, windowNameKey);
public ValueTask<bool> PlayCubePhaseAlertAsync()
=> jsRuntime.InvokeAsync<bool>("chesscubingAudio.playCubePhaseAlert");
public ValueTask PrimeAudioAsync()
=> jsRuntime.InvokeVoidAsync("chesscubingAudio.prime");
public ValueTask ForceRefreshAsync(string path)
=> jsRuntime.InvokeVoidAsync("chesscubingBrowser.forceRefresh", path);
}

View File

@@ -0,0 +1,53 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
namespace ChessCubing.App.Services;
public sealed class KeycloakAccountFactory(IAccessTokenProviderAccessor accessor)
: AccountClaimsPrincipalFactory<RemoteUserAccount>(accessor)
{
public override async ValueTask<ClaimsPrincipal> CreateUserAsync(
RemoteUserAccount account,
RemoteAuthenticationUserOptions options)
{
var user = await base.CreateUserAsync(account, options);
if (user.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
{
return user;
}
var roleClaimType = options.RoleClaim ?? "role";
var existingRoles = identity.FindAll(roleClaimType)
.Select(claim => claim.Value)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var role in ReadRealmRoles(account))
{
if (existingRoles.Add(role))
{
identity.AddClaim(new Claim(roleClaimType, role));
}
}
return user;
}
private static IEnumerable<string> ReadRealmRoles(RemoteUserAccount account)
{
if (!account.AdditionalProperties.TryGetValue("realm_access", out var realmAccessValue) ||
realmAccessValue is not JsonElement realmAccessElement ||
realmAccessElement.ValueKind != JsonValueKind.Object ||
!realmAccessElement.TryGetProperty("roles", out var rolesElement) ||
rolesElement.ValueKind != JsonValueKind.Array)
{
return [];
}
return rolesElement.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString())
.OfType<string>();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
using System.Net;
using System.Net.Http.Json;
using ChessCubing.App.Models;
using ChessCubing.App.Models.Stats;
namespace ChessCubing.App.Services;
public sealed class MatchStatsService(HttpClient httpClient)
{
private readonly HttpClient _httpClient = httpClient;
public async Task<bool> TryReportCompletedMatchAsync(MatchState? match, CancellationToken cancellationToken = default)
{
if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
{
return false;
}
if (string.IsNullOrWhiteSpace(match.WhiteSubject) && string.IsNullOrWhiteSpace(match.BlackSubject))
{
return false;
}
var request = BuildReport(match);
try
{
var response = await _httpClient.PostAsJsonAsync("api/users/me/stats/matches", request, cancellationToken);
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
return false;
}
if (!response.IsSuccessStatusCode)
{
return false;
}
match.ResultRecordedUtc = DateTime.UtcNow;
return true;
}
catch
{
return false;
}
}
private static ReportCompletedMatchRequest BuildReport(MatchState match)
=> new()
{
MatchId = string.IsNullOrWhiteSpace(match.MatchId) ? Guid.NewGuid().ToString("N") : match.MatchId,
CollaborationSessionId = match.CollaborationSessionId,
WhiteSubject = NormalizeOptional(match.WhiteSubject),
WhiteName = MatchEngine.PlayerName(match, MatchEngine.ColorWhite),
BlackSubject = NormalizeOptional(match.BlackSubject),
BlackName = MatchEngine.PlayerName(match, MatchEngine.ColorBlack),
Result = match.Result ?? string.Empty,
Mode = match.Config.Mode,
Preset = match.Config.Preset,
MatchLabel = MatchEngine.SanitizeText(match.Config.MatchLabel),
BlockNumber = Math.Max(1, match.BlockNumber),
WhiteMoves = Math.Max(0, match.Moves.White),
BlackMoves = Math.Max(0, match.Moves.Black),
CubeRounds = match.Cube.History
.Select(entry => new ReportCompletedCubeRound
{
BlockNumber = Math.Max(1, entry.BlockNumber),
Number = entry.Number,
White = entry.White,
Black = entry.Black,
})
.ToArray(),
};
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -0,0 +1,134 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ChessCubing.App.Models;
namespace ChessCubing.App.Services;
public sealed class MatchStore(BrowserBridge browser, UserSession userSession)
{
public const string StorageKeyPrefix = "chesscubing-arena-state-v3";
public const string WindowNameKeyPrefix = "chesscubing-arena-state-v3";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private bool _dirty;
private long _lastPersistedAt;
private string? _activeStorageKey;
private string? _activeWindowNameKey;
public MatchState? Current { get; private set; }
public bool IsLoaded { get; private set; }
public async Task EnsureLoadedAsync()
{
var storageScope = await SyncStorageScopeAsync();
if (IsLoaded)
{
return;
}
try
{
var raw = await browser.ReadMatchJsonAsync(storageScope.StorageKey, storageScope.WindowNameKey);
if (!string.IsNullOrWhiteSpace(raw))
{
var parsed = JsonSerializer.Deserialize<MatchState>(raw, JsonOptions);
if (parsed is not null && MatchEngine.IsSupportedSchemaVersion(parsed.SchemaVersion))
{
MatchEngine.NormalizeRecoveredMatch(parsed);
Current = parsed;
}
}
}
catch
{
Current = null;
}
IsLoaded = true;
_lastPersistedAt = MatchEngine.NowUnixMs();
}
public void SetCurrent(MatchState? match)
{
Current = match;
MarkDirty();
}
public void ReplaceCurrent(MatchState? match)
{
Current = match;
IsLoaded = true;
_dirty = false;
}
public void MarkDirty()
=> _dirty = true;
public async Task SaveAsync()
{
var storageScope = await SyncStorageScopeAsync();
if (!IsLoaded)
{
return;
}
if (Current is null)
{
await browser.ClearMatchAsync(storageScope.StorageKey, storageScope.WindowNameKey);
_dirty = false;
_lastPersistedAt = MatchEngine.NowUnixMs();
return;
}
var json = JsonSerializer.Serialize(Current, JsonOptions);
await browser.WriteMatchJsonAsync(storageScope.StorageKey, storageScope.WindowNameKey, json);
_dirty = false;
_lastPersistedAt = MatchEngine.NowUnixMs();
}
public async Task FlushIfDueAsync(long minimumIntervalMs = 1_000)
{
if (!_dirty)
{
return;
}
if (MatchEngine.NowUnixMs() - _lastPersistedAt < minimumIntervalMs)
{
return;
}
await SaveAsync();
}
public async Task ClearAsync()
{
var storageScope = await SyncStorageScopeAsync();
Current = null;
IsLoaded = true;
_dirty = false;
_lastPersistedAt = MatchEngine.NowUnixMs();
await browser.ClearMatchAsync(storageScope.StorageKey, storageScope.WindowNameKey);
}
private async ValueTask<UserStorageScope> SyncStorageScopeAsync()
{
var storageScope = await userSession.GetStorageScopeAsync();
if (_activeStorageKey is not null &&
(_activeStorageKey != storageScope.StorageKey || _activeWindowNameKey != storageScope.WindowNameKey))
{
Current = null;
IsLoaded = false;
_dirty = false;
}
_activeStorageKey = storageScope.StorageKey;
_activeWindowNameKey = storageScope.WindowNameKey;
return storageScope;
}
}

View File

@@ -0,0 +1,469 @@
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using ChessCubing.App.Models;
using ChessCubing.App.Models.Social;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.SignalR.Client;
namespace ChessCubing.App.Services;
public sealed class SocialRealtimeService(
NavigationManager navigation,
AuthenticationStateProvider authenticationStateProvider) : IAsyncDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly NavigationManager _navigation = navigation;
private readonly AuthenticationStateProvider _authenticationStateProvider = authenticationStateProvider;
private readonly SemaphoreSlim _gate = new(1, 1);
private HubConnection? _hubConnection;
private string? _currentSubject;
private string? _joinedPlaySessionId;
private HashSet<string> _knownPresenceSubjects = new(StringComparer.Ordinal);
private HashSet<string> _onlineSubjects = new(StringComparer.Ordinal);
private PlayInviteMessage? _incomingPlayInvite;
private PlayInviteMessage? _outgoingPlayInvite;
private PlaySessionResponse? _activePlaySession;
private CollaborativeMatchSnapshot? _collaborativeSnapshot;
private string? _lastInviteNotice;
private int _socialVersion;
private bool _started;
public event Action? Changed;
public PlayInviteMessage? IncomingPlayInvite => _incomingPlayInvite;
public PlayInviteMessage? OutgoingPlayInvite => _outgoingPlayInvite;
public PlaySessionResponse? ActivePlaySession => _activePlaySession;
public CollaborativeMatchSnapshot? CollaborativeSnapshot => _collaborativeSnapshot;
public string? LastInviteNotice => _lastInviteNotice;
public int SocialVersion => _socialVersion;
public async Task EnsureStartedAsync()
{
if (_started)
{
await SyncConnectionAsync();
return;
}
_started = true;
_authenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
await SyncConnectionAsync();
}
public bool IsUserOnline(string subject)
=> _onlineSubjects.Contains(subject);
public bool? GetKnownOnlineStatus(string subject)
{
if (_onlineSubjects.Contains(subject))
{
return true;
}
return _knownPresenceSubjects.Contains(subject)
? false
: null;
}
public async Task EnsureJoinedPlaySessionAsync(string? sessionId)
{
var normalizedSessionId = sessionId?.Trim();
if (string.IsNullOrWhiteSpace(normalizedSessionId))
{
return;
}
await EnsureStartedAsync();
await JoinPlaySessionCoreAsync(normalizedSessionId);
}
public async Task PublishMatchStateAsync(MatchState? match, string route)
{
var sessionId = match?.CollaborationSessionId
?? _activePlaySession?.SessionId
?? _joinedPlaySessionId;
if (string.IsNullOrWhiteSpace(sessionId))
{
return;
}
await EnsureJoinedPlaySessionAsync(sessionId);
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
{
throw new InvalidOperationException("La connexion temps reel n'est pas prete.");
}
var payload = match is null
? null
: JsonSerializer.Serialize(match, JsonOptions);
await _hubConnection.InvokeAsync("PublishMatchState", sessionId, payload, route);
}
public async Task SendPlayInviteAsync(string recipientSubject, string recipientColor)
{
_lastInviteNotice = null;
if (_hubConnection is null)
{
throw new InvalidOperationException("La connexion temps reel n'est pas prete.");
}
var invite = await _hubConnection.InvokeAsync<PlayInviteMessage>("SendPlayInvite", recipientSubject, recipientColor);
ApplyInvite(invite);
RaiseChanged();
}
public async Task RespondToIncomingPlayInviteAsync(bool accept)
{
if (_hubConnection is null || _incomingPlayInvite is null)
{
return;
}
var inviteId = _incomingPlayInvite.InviteId;
await _hubConnection.InvokeAsync("RespondToPlayInvite", inviteId, accept);
if (!accept)
{
_incomingPlayInvite = null;
RaiseChanged();
}
}
public async Task CancelOutgoingPlayInviteAsync()
{
if (_hubConnection is null || _outgoingPlayInvite is null)
{
return;
}
var inviteId = _outgoingPlayInvite.InviteId;
await _hubConnection.InvokeAsync("CancelPlayInvite", inviteId);
}
public void ClearInviteNotice()
{
if (string.IsNullOrWhiteSpace(_lastInviteNotice))
{
return;
}
_lastInviteNotice = null;
RaiseChanged();
}
public async ValueTask DisposeAsync()
{
_authenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
await _gate.WaitAsync();
try
{
if (_hubConnection is not null)
{
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
}
finally
{
_gate.Release();
_gate.Dispose();
}
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
=> _ = SyncConnectionAsync();
private async Task SyncConnectionAsync()
{
await _gate.WaitAsync();
try
{
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
var subject = ResolveSubject(user);
var preserveSessionState = string.Equals(subject, _currentSubject, StringComparison.Ordinal);
if (string.IsNullOrWhiteSpace(subject))
{
await DisconnectUnsafeAsync(clearSessionState: true);
return;
}
if (_hubConnection is not null &&
string.Equals(subject, _currentSubject, StringComparison.Ordinal) &&
_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
{
return;
}
await DisconnectUnsafeAsync(clearSessionState: !preserveSessionState);
_currentSubject = subject;
_hubConnection = BuildHubConnection();
RegisterHandlers(_hubConnection);
await _hubConnection.StartAsync();
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId))
{
await JoinPlaySessionCoreAsync(_joinedPlaySessionId);
}
}
finally
{
_gate.Release();
}
}
private HubConnection BuildHubConnection()
=> new HubConnectionBuilder()
.WithUrl(_navigation.ToAbsoluteUri("/hubs/social"))
.WithAutomaticReconnect()
.Build();
private void RegisterHandlers(HubConnection connection)
{
connection.On<PresenceSnapshotMessage>("PresenceSnapshot", message =>
{
_knownPresenceSubjects = message.OnlineSubjects.ToHashSet(StringComparer.Ordinal);
_onlineSubjects = message.OnlineSubjects.ToHashSet(StringComparer.Ordinal);
RaiseChanged();
});
connection.On<PresenceChangedMessage>("PresenceChanged", message =>
{
_knownPresenceSubjects.Add(message.Subject);
if (message.IsOnline)
{
_onlineSubjects.Add(message.Subject);
}
else
{
_onlineSubjects.Remove(message.Subject);
}
RaiseChanged();
});
connection.On("SocialChanged", async () =>
{
Interlocked.Increment(ref _socialVersion);
await RequestPresenceSnapshotAsync();
RaiseChanged();
});
connection.On<PlayInviteMessage>("PlayInviteUpdated", message =>
{
ApplyInvite(message);
RaiseChanged();
});
connection.On<PlayInviteClosedMessage>("PlayInviteClosed", message =>
{
if (_incomingPlayInvite?.InviteId == message.InviteId)
{
_incomingPlayInvite = null;
}
if (_outgoingPlayInvite?.InviteId == message.InviteId)
{
_outgoingPlayInvite = null;
}
_lastInviteNotice = message.Message;
RaiseChanged();
});
connection.On<CollaborativeMatchStateMessage>("CollaborativeMatchStateUpdated", message =>
{
ApplyCollaborativeState(message);
RaiseChanged();
});
connection.On<PlaySessionResponse>("PlayInviteAccepted", async session =>
{
_incomingPlayInvite = null;
_outgoingPlayInvite = null;
_activePlaySession = session;
_lastInviteNotice = "La partie est confirmee. Les deux ecrans resteront synchronises pendant le match.";
await JoinPlaySessionCoreAsync(session.SessionId);
RaiseChanged();
});
connection.Reconnected += async _ =>
{
await RequestPresenceSnapshotAsync();
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId))
{
await JoinPlaySessionCoreAsync(_joinedPlaySessionId);
}
RaiseChanged();
};
connection.Closed += async _ =>
{
if (!string.IsNullOrWhiteSpace(_currentSubject))
{
await SyncConnectionAsync();
}
};
}
private async Task RequestPresenceSnapshotAsync()
{
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
{
return;
}
try
{
await _hubConnection.InvokeAsync("RequestPresenceSnapshot");
}
catch
{
// La vue peut continuer avec le dernier etat connu si le snapshot echoue ponctuellement.
}
}
private async Task JoinPlaySessionCoreAsync(string sessionId)
{
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
{
return;
}
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId) &&
!string.Equals(_joinedPlaySessionId, sessionId, StringComparison.Ordinal))
{
try
{
await _hubConnection.InvokeAsync("LeavePlaySession", _joinedPlaySessionId);
}
catch
{
// La prochaine connexion recreera les groupes si besoin.
}
}
var latestState = await _hubConnection.InvokeAsync<CollaborativeMatchStateMessage?>("JoinPlaySession", sessionId);
_joinedPlaySessionId = sessionId;
if (latestState is not null)
{
ApplyCollaborativeState(latestState);
}
else if (_collaborativeSnapshot?.SessionId != sessionId)
{
_collaborativeSnapshot = null;
}
}
private void ApplyInvite(PlayInviteMessage invite)
{
if (string.IsNullOrWhiteSpace(_currentSubject))
{
return;
}
if (string.Equals(invite.RecipientSubject, _currentSubject, StringComparison.Ordinal))
{
_incomingPlayInvite = invite;
}
if (string.Equals(invite.SenderSubject, _currentSubject, StringComparison.Ordinal))
{
_outgoingPlayInvite = invite;
}
}
private void ApplyCollaborativeState(CollaborativeMatchStateMessage message)
{
if (_collaborativeSnapshot is not null &&
string.Equals(_collaborativeSnapshot.SessionId, message.SessionId, StringComparison.Ordinal) &&
message.Revision <= _collaborativeSnapshot.Revision)
{
return;
}
MatchState? match = null;
if (!string.IsNullOrWhiteSpace(message.MatchJson))
{
try
{
match = JsonSerializer.Deserialize<MatchState>(message.MatchJson, JsonOptions);
if (match is not null)
{
match.CollaborationSessionId = message.SessionId;
MatchEngine.NormalizeRecoveredMatch(match);
}
}
catch
{
match = null;
}
}
_collaborativeSnapshot = new CollaborativeMatchSnapshot
{
SessionId = message.SessionId,
Match = match,
Route = string.IsNullOrWhiteSpace(message.Route) ? "/application.html" : message.Route,
SenderSubject = message.SenderSubject,
Revision = message.Revision,
UpdatedUtc = message.UpdatedUtc,
};
}
private async Task DisconnectUnsafeAsync(bool clearSessionState)
{
_currentSubject = clearSessionState ? null : _currentSubject;
_knownPresenceSubjects.Clear();
_onlineSubjects.Clear();
_incomingPlayInvite = null;
_outgoingPlayInvite = null;
if (clearSessionState)
{
_activePlaySession = null;
_joinedPlaySessionId = null;
_collaborativeSnapshot = null;
_lastInviteNotice = null;
}
if (_hubConnection is not null)
{
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
RaiseChanged();
}
private void RaiseChanged()
=> Changed?.Invoke();
private static string? ResolveSubject(ClaimsPrincipal user)
=> user.Identity?.IsAuthenticated == true
? user.FindFirst("sub")?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
: null;
}

View File

@@ -0,0 +1,49 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
namespace ChessCubing.App.Services;
public sealed class UserSession(AuthenticationStateProvider authenticationStateProvider)
{
public async ValueTask<UserStorageScope> GetStorageScopeAsync()
{
try
{
var authState = await authenticationStateProvider.GetAuthenticationStateAsync();
var userId = ResolveUserId(authState.User);
return CreateStorageScope(userId);
}
catch
{
return CreateStorageScope("anonymous");
}
}
private static string ResolveUserId(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
{
return "anonymous";
}
var rawIdentifier = user.FindFirst("sub")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst("preferred_username")?.Value
?? user.Identity?.Name;
if (string.IsNullOrWhiteSpace(rawIdentifier))
{
return "authenticated";
}
return Uri.EscapeDataString(rawIdentifier.Trim());
}
private static UserStorageScope CreateStorageScope(string userId)
=> new(
$"{MatchStore.StorageKeyPrefix}:{userId}",
$"{MatchStore.WindowNameKeyPrefix}:{userId}:");
}
public readonly record struct UserStorageScope(string StorageKey, string WindowNameKey);

View File

@@ -0,0 +1,15 @@
@using System.Globalization
@using System.Net.Http
@using ChessCubing.App
@using ChessCubing.App.Components
@using ChessCubing.App.Models
@using ChessCubing.App.Models.Social
@using ChessCubing.App.Services
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Microsoft.JSInterop

View File

@@ -0,0 +1,13 @@
{
"Keycloak": {
"Authority": "/auth/realms/chesscubing",
"ClientId": "chesscubing-web",
"ResponseType": "code",
"PostLogoutRedirectUri": "/",
"DefaultScopes": [
"openid",
"profile",
"email"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#140700" />
<meta name="application-name" content="ChessCubing Arena" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ChessCubing" />
<meta name="description" content="Application officielle ChessCubing Arena en Blazor .NET 10." />
<title>ChessCubing Arena</title>
<base href="/" />
<link rel="preload" id="webassembly" />
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="shortcut icon" href="favicon.png" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="styles.css" />
<link href="ChessCubing.App.styles.css" rel="stylesheet" />
<script src="js/chesscubing-interop.js"></script>
<script type="importmap"></script>
</head>
<body>
<div id="app">
<div style="min-height: 100dvh; display: grid; place-items: center; padding: 2rem; color: #f5f7fb;">
<div style="text-align: center;">
<strong style="display: block; margin-bottom: 0.75rem;">ChessCubing Arena</strong>
<span>Chargement de l'application Blazor...</span>
</div>
</div>
</div>
<div id="blazor-error-ui">
Une erreur inattendue est survenue.
<a href="." class="reload">Recharger</a>
<span class="dismiss">x</span>
</div>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>
</html>

View File

@@ -0,0 +1,347 @@
(() => {
const assetTokenStorageKey = "chesscubing-arena-asset-token";
let viewportStarted = false;
let audioContext = null;
let authModalMessageHandler = null;
let menuScrollStarted = false;
let menuLastScrollY = 0;
let menuAnimationFrame = 0;
let appliedBodyStateClasses = [];
function syncViewportHeight() {
const visibleHeight = window.visualViewport?.height ?? window.innerHeight;
const viewportTopOffset = window.visualViewport?.offsetTop ?? 0;
const viewportHeight = Math.max(
visibleHeight + viewportTopOffset,
window.innerHeight,
document.documentElement.clientHeight,
);
document.documentElement.style.setProperty("--app-visible-height", `${Math.round(visibleHeight)}px`);
document.documentElement.style.setProperty("--app-viewport-top", `${Math.round(viewportTopOffset)}px`);
document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`);
}
function startViewport() {
if (viewportStarted) {
return;
}
viewportStarted = true;
syncViewportHeight();
window.addEventListener("load", syncViewportHeight);
window.addEventListener("resize", syncViewportHeight);
window.addEventListener("scroll", syncViewportHeight, { passive: true });
window.addEventListener("pageshow", syncViewportHeight);
window.addEventListener("orientationchange", syncViewportHeight);
window.visualViewport?.addEventListener("resize", syncViewportHeight);
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
window.setTimeout(syncViewportHeight, 0);
window.setTimeout(syncViewportHeight, 150);
window.setTimeout(syncViewportHeight, 400);
window.requestAnimationFrame(() => window.requestAnimationFrame(syncViewportHeight));
}
function setMenuHidden(hidden) {
document.body.classList.toggle("site-menu-hidden", hidden);
}
function shouldHideMenuForModal(modal) {
if (!modal) {
return false;
}
return window.matchMedia("(max-width: 900px)").matches && modal.querySelector(".auth-modal-card") !== null;
}
function syncMenuVisibility() {
menuAnimationFrame = 0;
const menu = document.querySelector(".site-menu-shell");
if (!menu) {
setMenuHidden(false);
menuLastScrollY = window.scrollY || window.pageYOffset || 0;
return;
}
const modalIsOpen = document.querySelector(".modal:not(.hidden)");
if (modalIsOpen) {
setMenuHidden(shouldHideMenuForModal(modalIsOpen));
menuLastScrollY = window.scrollY || window.pageYOffset || 0;
return;
}
const mobileMenuIsOpen =
window.matchMedia("(max-width: 900px)").matches &&
document.querySelector(".site-menu-main.is-mobile-open") !== null;
if (mobileMenuIsOpen) {
setMenuHidden(false);
menuLastScrollY = window.scrollY || window.pageYOffset || 0;
return;
}
const currentScrollY = Math.max(window.scrollY || window.pageYOffset || 0, 0);
const delta = currentScrollY - menuLastScrollY;
if (currentScrollY <= 24 || delta < -10) {
setMenuHidden(false);
} else if (delta > 10 && currentScrollY > 120) {
setMenuHidden(true);
}
menuLastScrollY = currentScrollY;
}
function queueMenuVisibilitySync() {
if (menuAnimationFrame) {
return;
}
menuAnimationFrame = window.requestAnimationFrame(syncMenuVisibility);
}
function startMenuScrollTracking() {
if (menuScrollStarted) {
queueMenuVisibilitySync();
return;
}
menuScrollStarted = true;
menuLastScrollY = window.scrollY || window.pageYOffset || 0;
window.addEventListener("scroll", queueMenuVisibilitySync, { passive: true });
window.addEventListener("pageshow", queueMenuVisibilitySync);
window.addEventListener("resize", queueMenuVisibilitySync);
window.addEventListener("hashchange", queueMenuVisibilitySync);
window.setTimeout(queueMenuVisibilitySync, 0);
window.setTimeout(queueMenuVisibilitySync, 150);
}
function getAudioContext() {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) {
return null;
}
if (!audioContext) {
try {
audioContext = new AudioContextClass();
} catch {
return null;
}
}
return audioContext;
}
function primeAudio() {
const context = getAudioContext();
if (!context || context.state === "running") {
return;
}
context.resume().catch(() => undefined);
}
function playCubePhaseAlert() {
primeAudio();
const context = getAudioContext();
if (!context || context.state !== "running") {
return false;
}
const pattern = [
{ frequency: 740, offset: 0, duration: 0.12, gain: 0.035 },
{ frequency: 988, offset: 0.18, duration: 0.12, gain: 0.04 },
{ frequency: 1318, offset: 0.36, duration: 0.2, gain: 0.05 },
];
const startAt = context.currentTime + 0.02;
pattern.forEach(({ frequency, offset, duration, gain }) => {
const oscillator = context.createOscillator();
const envelope = context.createGain();
const toneStartAt = startAt + offset;
oscillator.type = "triangle";
oscillator.frequency.setValueAtTime(frequency, toneStartAt);
envelope.gain.setValueAtTime(0.0001, toneStartAt);
envelope.gain.exponentialRampToValueAtTime(gain, toneStartAt + 0.02);
envelope.gain.exponentialRampToValueAtTime(0.0001, toneStartAt + duration);
oscillator.connect(envelope);
envelope.connect(context.destination);
oscillator.start(toneStartAt);
oscillator.stop(toneStartAt + duration + 0.03);
});
return true;
}
window.chesscubingPage = {
setBodyState(page, bodyClass) {
if (page) {
document.body.dataset.page = page;
} else {
delete document.body.dataset.page;
}
for (const className of appliedBodyStateClasses) {
document.body.classList.remove(className);
}
appliedBodyStateClasses = (bodyClass || "")
.split(/\s+/)
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (appliedBodyStateClasses.length > 0) {
document.body.classList.add(...appliedBodyStateClasses);
}
queueMenuVisibilitySync();
},
};
window.chesscubingViewport = {
start: startViewport,
};
window.chesscubingMenu = {
start: startMenuScrollTracking,
sync: queueMenuVisibilitySync,
};
window.chesscubingStorage = {
getMatchState(storageKey, windowNameKey) {
try {
const raw = window.localStorage.getItem(storageKey);
if (raw) {
return raw;
}
} catch {
}
try {
if (!window.name || !window.name.startsWith(windowNameKey)) {
return null;
}
return window.name.slice(windowNameKey.length);
} catch {
return null;
}
},
setMatchState(storageKey, windowNameKey, value) {
try {
window.name = `${windowNameKey}${value}`;
} catch {
}
try {
window.localStorage.setItem(storageKey, value);
} catch {
}
},
clearMatchState(storageKey, windowNameKey) {
try {
window.localStorage.removeItem(storageKey);
} catch {
}
try {
if (window.name && window.name.startsWith(windowNameKey)) {
window.name = "";
}
} catch {
}
},
};
window.chesscubingAudio = {
prime: primeAudio,
playCubePhaseAlert,
};
window.chesscubingBrowser = {
async forceRefresh(path) {
const refreshToken = `${Date.now()}`;
try {
window.sessionStorage.setItem(assetTokenStorageKey, refreshToken);
} catch {
}
if ("caches" in window) {
try {
const cacheKeys = await window.caches.keys();
await Promise.all(cacheKeys.map((cacheKey) => window.caches.delete(cacheKey)));
} catch {
}
}
if ("serviceWorker" in navigator) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.update().catch(() => undefined)));
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => undefined)));
} catch {
}
}
const targetUrl = new URL(path, window.location.href);
targetUrl.searchParams.set("refresh", refreshToken);
window.location.replace(targetUrl.toString());
},
};
window.chesscubingAuthModal = {
registerListener(dotNetReference) {
if (authModalMessageHandler) {
window.removeEventListener("message", authModalMessageHandler);
}
authModalMessageHandler = (event) => {
if (event.origin !== window.location.origin) {
return;
}
const data = event.data;
if (!data || data.source !== "chesscubing-auth-modal" || typeof data.status !== "string") {
return;
}
dotNetReference.invokeMethodAsync("HandleAuthModalMessage", data.status).catch(() => undefined);
};
window.addEventListener("message", authModalMessageHandler);
},
unregisterListener() {
if (!authModalMessageHandler) {
return;
}
window.removeEventListener("message", authModalMessageHandler);
authModalMessageHandler = null;
},
notifyParent(status) {
if (!window.parent || window.parent === window) {
return;
}
window.parent.postMessage(
{
source: "chesscubing-auth-modal",
status,
},
window.location.origin,
);
},
};
startViewport();
startMenuScrollTracking();
})();

View File

@@ -0,0 +1,154 @@
namespace ChessCubing.Server.Admin;
public sealed class AdminUserSummaryResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string? Email { get; init; }
public string IdentityDisplayName { get; init; } = string.Empty;
public string? SiteDisplayName { get; init; }
public bool IsEnabled { get; init; }
public bool IsEmailVerified { get; init; }
public bool HasSiteProfile { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public DateTime? AccountCreatedUtc { get; init; }
public DateTime? SiteProfileUpdatedUtc { get; init; }
}
public sealed class AdminUserDetailResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string? Email { get; init; }
public string? FirstName { get; init; }
public string? LastName { get; init; }
public string IdentityDisplayName { get; init; } = string.Empty;
public bool IsEnabled { get; init; }
public bool IsEmailVerified { get; init; }
public DateTime? AccountCreatedUtc { get; init; }
public bool HasSiteProfile { get; init; }
public string DisplayName { get; init; } = string.Empty;
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public string? FavoriteCube { get; init; }
public string? Bio { get; init; }
public DateTime? SiteProfileCreatedUtc { get; init; }
public DateTime? SiteProfileUpdatedUtc { get; init; }
}
public sealed class AdminUpdateUserRequest
{
public string? Email { get; init; }
public string? FirstName { get; init; }
public string? LastName { get; init; }
public bool IsEnabled { get; init; }
public bool IsEmailVerified { get; init; }
public string? DisplayName { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public string? FavoriteCube { get; init; }
public string? Bio { get; init; }
}
public sealed class AdminCreateUserRequest
{
public string Username { get; init; } = string.Empty;
public string? Email { get; init; }
public string Password { get; init; } = string.Empty;
public string ConfirmPassword { get; init; } = string.Empty;
public string? FirstName { get; init; }
public string? LastName { get; init; }
public bool IsEnabled { get; init; } = true;
public bool IsEmailVerified { get; init; }
public string? DisplayName { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public string? FavoriteCube { get; init; }
public string? Bio { get; init; }
}
public sealed record AdminIdentityUser(
string Subject,
string Username,
string? Email,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified,
DateTime? CreatedUtc);
public sealed record AdminIdentityUserUpdateRequest(
string Username,
string? Email,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified);
public sealed record AdminIdentityUserCreateRequest(
string Username,
string? Email,
string Password,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified);
public sealed class AdminUserValidationException(string message) : Exception(message);

View File

@@ -0,0 +1,59 @@
using System.Security.Claims;
using System.Text.Json.Serialization;
namespace ChessCubing.Server.Auth;
public sealed record LoginRequest(string Username, string Password);
public sealed record RegisterRequest(
string Username,
string Email,
string Password,
string ConfirmPassword,
string? FirstName,
string? LastName);
public sealed record ApiErrorResponse(string Message);
public sealed class AuthSessionResponse
{
public bool IsAuthenticated { get; init; }
public string? Subject { get; init; }
public string? Username { get; init; }
public string? Name { get; init; }
public string? Email { get; init; }
public string[] Roles { get; init; } = [];
public static AuthSessionResponse FromUser(ClaimsPrincipal user)
=> new()
{
IsAuthenticated = user.Identity?.IsAuthenticated == true,
Subject = user.FindFirst("sub")?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
Username = user.FindFirst("preferred_username")?.Value ?? user.Identity?.Name,
Name = user.FindFirst("name")?.Value ?? user.Identity?.Name,
Email = user.FindFirst("email")?.Value,
Roles = user.FindAll("role").Select(claim => claim.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
};
}
public sealed class KeycloakUserInfo
{
[JsonPropertyName("sub")]
public string? Subject { get; init; }
[JsonPropertyName("preferred_username")]
public string? PreferredUsername { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
public string[] Roles { get; set; } = [];
}

View File

@@ -0,0 +1,18 @@
namespace ChessCubing.Server.Auth;
public sealed class KeycloakAuthOptions
{
public string BaseUrl { get; set; } = "http://keycloak:8080/auth";
public string Realm { get; set; } = "chesscubing";
public string ClientId { get; set; } = "chesscubing-web";
public string AdminRealm { get; set; } = "master";
public string AdminClientId { get; set; } = "admin-cli";
public string AdminUsername { get; set; } = "admin";
public string AdminPassword { get; set; } = "admin";
}

View File

@@ -0,0 +1,542 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using ChessCubing.Server.Admin;
using Microsoft.Extensions.Options;
namespace ChessCubing.Server.Auth;
public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<KeycloakAuthOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static readonly JsonSerializerOptions UpdateJsonOptions = new(JsonOptions)
{
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
private readonly HttpClient _httpClient = httpClient;
private readonly KeycloakAuthOptions _options = options.Value;
public async Task<KeycloakUserInfo> LoginAsync(string username, string password, CancellationToken cancellationToken)
{
var tokenResponse = await RequestPasswordTokenAsync(username, password, cancellationToken);
return await GetUserInfoAsync(tokenResponse.AccessToken!, cancellationToken);
}
public async Task<KeycloakUserInfo> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
var userId = await CreateUserAsync(
adminToken,
request.Username,
request.Email,
request.FirstName,
request.LastName,
isEnabled: true,
isEmailVerified: false,
cancellationToken);
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
return await LoginAsync(request.Username, request.Password, cancellationToken);
}
public async Task<AdminIdentityUser> CreateAdminUserAsync(
AdminIdentityUserCreateRequest request,
CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
var userId = await CreateUserAsync(
adminToken,
request.Username,
request.Email,
request.FirstName,
request.LastName,
request.IsEnabled,
request.IsEmailVerified,
cancellationToken);
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
}
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
var users = new List<AdminIdentityUser>();
const int pageSize = 100;
for (var first = 0; ; first += pageSize)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{GetAdminBaseUrl()}/users?first={first}&max={pageSize}&briefRepresentation=false");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible de recuperer la liste des utilisateurs Keycloak.", (int)response.StatusCode);
}
var page = await ReadJsonAsync<List<AdminUserRepresentation>>(response, cancellationToken) ?? [];
users.AddRange(page
.Where(user => !string.IsNullOrWhiteSpace(user.Id) && !string.IsNullOrWhiteSpace(user.Username))
.Select(MapAdminIdentityUser));
if (page.Count < pageSize)
{
break;
}
}
return users;
}
public async Task<AdminIdentityUser> GetAdminUserAsync(string userId, CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
}
public async Task<AdminIdentityUser> UpdateAdminUserAsync(
string userId,
AdminIdentityUserUpdateRequest request,
CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
using (var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}")
{
Content = JsonContent.Create(new
{
username = request.Username,
email = request.Email,
enabled = request.IsEnabled,
emailVerified = request.IsEmailVerified,
firstName = request.FirstName,
lastName = request.LastName,
}, options: UpdateJsonOptions)
})
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict)
{
throw new KeycloakAuthException("Ce nom d'utilisateur ou cet email existe deja.", StatusCodes.Status409Conflict);
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("La mise a jour du compte Keycloak a echoue.", (int)response.StatusCode);
}
}
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
}
public async Task DeleteAdminUserAsync(string userId, CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("La suppression du compte Keycloak a echoue.", (int)response.StatusCode);
}
}
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
{
var formData = new Dictionary<string, string>
{
["client_id"] = _options.ClientId,
["grant_type"] = "password",
["scope"] = "openid profile email",
["username"] = username,
["password"] = password,
};
using var response = await _httpClient.PostAsync(
$"{GetRealmBaseUrl()}/protocol/openid-connect/token",
new FormUrlEncodedContent(formData),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await ReadJsonAsync<TokenErrorResponse>(response, cancellationToken);
var message = error?.ErrorDescription switch
{
not null when error.ErrorDescription.Contains("Account is not fully set up", StringComparison.OrdinalIgnoreCase)
=> "Le compte existe mais n'est pas encore actif dans Keycloak.",
not null => "Identifiants invalides ou connexion Keycloak indisponible.",
_ => "Connexion Keycloak impossible.",
};
throw new KeycloakAuthException(message, (int)response.StatusCode);
}
var token = await ReadJsonAsync<TokenSuccessResponse>(response, cancellationToken);
if (token?.AccessToken is null)
{
throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton utilisable.");
}
return token;
}
private async Task<string> RequestAdminTokenAsync(CancellationToken cancellationToken)
{
var formData = new Dictionary<string, string>
{
["client_id"] = _options.AdminClientId,
["grant_type"] = "password",
["username"] = _options.AdminUsername,
["password"] = _options.AdminPassword,
};
using var response = await _httpClient.PostAsync(
$"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.AdminRealm)}/protocol/openid-connect/token",
new FormUrlEncodedContent(formData),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible d'obtenir un acces admin Keycloak.", (int)response.StatusCode);
}
var token = await ReadJsonAsync<TokenSuccessResponse>(response, cancellationToken);
if (string.IsNullOrWhiteSpace(token?.AccessToken))
{
throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton admin.");
}
return token.AccessToken;
}
private async Task<KeycloakUserInfo> GetUserInfoAsync(string accessToken, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"{GetRealmBaseUrl()}/protocol/openid-connect/userinfo");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible de recuperer le profil utilisateur Keycloak.", (int)response.StatusCode);
}
var userInfo = await ReadJsonAsync<KeycloakUserInfo>(response, cancellationToken);
if (userInfo is null)
{
throw new KeycloakAuthException("Le profil Keycloak est invalide.");
}
userInfo.Roles = ExtractRealmRoles(accessToken);
return userInfo;
}
private async Task<string> CreateUserAsync(
string adminToken,
string username,
string? email,
string? firstName,
string? lastName,
bool isEnabled,
bool isEmailVerified,
CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users")
{
Content = JsonContent.Create(new
{
username = username.Trim(),
email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
enabled = isEnabled,
emailVerified = isEmailVerified,
firstName = string.IsNullOrWhiteSpace(firstName) ? null : firstName.Trim(),
lastName = string.IsNullOrWhiteSpace(lastName) ? null : lastName.Trim(),
}, options: JsonOptions)
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict)
{
throw new KeycloakAuthException("Ce nom d'utilisateur ou cet email existe deja.", StatusCodes.Status409Conflict);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Creation du compte impossible dans Keycloak.", (int)response.StatusCode);
}
var userId = response.Headers.Location?.Segments.LastOrDefault();
if (!string.IsNullOrWhiteSpace(userId))
{
return userId.Trim('/');
}
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, username, cancellationToken);
if (!string.IsNullOrWhiteSpace(fallbackUserId))
{
return fallbackUserId;
}
throw new KeycloakAuthException("Le compte a ete cree mais l'identifiant Keycloak est introuvable.");
}
private async Task SetPasswordAsync(string adminToken, string userId, string password, CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/reset-password")
{
Content = JsonContent.Create(new
{
type = "password",
value = password,
temporary = false,
})
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Le compte a ete cree mais le mot de passe n'a pas pu etre defini.", (int)response.StatusCode);
}
}
private async Task TryAssignPlayerRoleAsync(string adminToken, string userId, CancellationToken cancellationToken)
{
using var roleRequest = new HttpRequestMessage(HttpMethod.Get, $"{GetAdminBaseUrl()}/roles/player");
roleRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var roleResponse = await _httpClient.SendAsync(roleRequest, cancellationToken);
if (!roleResponse.IsSuccessStatusCode)
{
return;
}
var role = await ReadJsonAsync<RealmRoleRepresentation>(roleResponse, cancellationToken);
if (role?.Name is null)
{
return;
}
using var assignRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/role-mappings/realm")
{
Content = JsonContent.Create(new[]
{
new RealmRoleRepresentation
{
Id = role.Id,
Name = role.Name,
Description = role.Description,
}
})
};
assignRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var assignResponse = await _httpClient.SendAsync(assignRequest, cancellationToken);
_ = assignResponse.IsSuccessStatusCode;
}
private async Task<string?> FindUserIdByUsernameAsync(string adminToken, string username, CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(
HttpMethod.Get,
$"{GetAdminBaseUrl()}/users?username={Uri.EscapeDataString(username)}&exact=true");
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
var users = await ReadJsonAsync<List<UserRepresentation>>(response, cancellationToken);
return users?.FirstOrDefault()?.Id;
}
private async Task<AdminIdentityUser> GetAdminUserAsync(string adminToken, string userId, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible de recuperer le compte Keycloak.", (int)response.StatusCode);
}
var user = await ReadJsonAsync<AdminUserRepresentation>(response, cancellationToken);
if (user is null || string.IsNullOrWhiteSpace(user.Id) || string.IsNullOrWhiteSpace(user.Username))
{
throw new KeycloakAuthException("Le compte Keycloak est invalide.");
}
return MapAdminIdentityUser(user);
}
private static AdminIdentityUser MapAdminIdentityUser(AdminUserRepresentation user)
=> new(
user.Id!,
user.Username!,
user.Email,
user.FirstName,
user.LastName,
user.Enabled ?? true,
user.EmailVerified ?? false,
user.CreatedTimestamp is > 0
? DateTimeOffset.FromUnixTimeMilliseconds(user.CreatedTimestamp.Value).UtcDateTime
: null);
private string[] ExtractRealmRoles(string accessToken)
{
try
{
var tokenParts = accessToken.Split('.');
if (tokenParts.Length < 2)
{
return [];
}
var payloadBytes = DecodeBase64Url(tokenParts[1]);
using var document = JsonDocument.Parse(payloadBytes);
if (!document.RootElement.TryGetProperty("realm_access", out var realmAccess) ||
!realmAccess.TryGetProperty("roles", out var rolesElement) ||
rolesElement.ValueKind != JsonValueKind.Array)
{
return [];
}
return rolesElement
.EnumerateArray()
.Select(role => role.GetString())
.Where(role => !string.IsNullOrWhiteSpace(role))
.Cast<string>()
.ToArray();
}
catch
{
return [];
}
}
private static byte[] DecodeBase64Url(string input)
{
var normalized = input.Replace('-', '+').Replace('_', '/');
normalized = normalized.PadRight(normalized.Length + (4 - normalized.Length % 4) % 4, '=');
return Convert.FromBase64String(normalized);
}
private string GetBaseUrl()
=> _options.BaseUrl.TrimEnd('/');
private string GetRealmBaseUrl()
=> $"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.Realm)}";
private string GetAdminBaseUrl()
=> $"{GetBaseUrl()}/admin/realms/{Uri.EscapeDataString(_options.Realm)}";
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(content))
{
return default;
}
return JsonSerializer.Deserialize<T>(content, JsonOptions);
}
private sealed class TokenSuccessResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; init; }
}
private sealed class TokenErrorResponse
{
[JsonPropertyName("error")]
public string? Error { get; init; }
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; init; }
}
private sealed class UserRepresentation
{
[JsonPropertyName("id")]
public string? Id { get; init; }
}
private sealed class RealmRoleRepresentation
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
private sealed class AdminUserRepresentation
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("username")]
public string? Username { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
[JsonPropertyName("firstName")]
public string? FirstName { get; init; }
[JsonPropertyName("lastName")]
public string? LastName { get; init; }
[JsonPropertyName("enabled")]
public bool? Enabled { get; init; }
[JsonPropertyName("emailVerified")]
public bool? EmailVerified { get; init; }
[JsonPropertyName("createdTimestamp")]
public long? CreatedTimestamp { get; init; }
}
}
public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ChessCubing.Server</RootNamespace>
<AssemblyName>ChessCubing.Server</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MySqlConnector" Version="2.4.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
using MySqlConnector;
namespace ChessCubing.Server.Data;
public sealed class SiteDataOptions
{
public string Host { get; set; } = "mysql";
public int Port { get; set; } = 3306;
public string Database { get; set; } = "chesscubing_site";
public string Username { get; set; } = "chesscubing";
public string Password { get; set; } = "chesscubing";
public int InitializationRetries { get; set; } = 12;
public int InitializationDelaySeconds { get; set; } = 3;
public string BuildConnectionString()
=> new MySqlConnectionStringBuilder
{
Server = Host,
Port = checked((uint)Port),
Database = Database,
UserID = Username,
Password = Password,
CharacterSet = "utf8mb4",
Pooling = true,
ConnectionTimeout = 15,
}.ConnectionString;
}

View File

@@ -0,0 +1,843 @@
using System.Security.Claims;
using System.Net.Mail;
using ChessCubing.Server.Admin;
using ChessCubing.Server.Auth;
using ChessCubing.Server.Data;
using ChessCubing.Server.Social;
using ChessCubing.Server.Stats;
using ChessCubing.Server.Users;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<KeycloakAuthOptions>()
.Configure<IConfiguration>((options, configuration) =>
{
options.BaseUrl = configuration["KEYCLOAK_BASE_URL"] ?? options.BaseUrl;
options.Realm = configuration["KEYCLOAK_REALM"] ?? options.Realm;
options.ClientId = configuration["KEYCLOAK_CLIENT_ID"] ?? options.ClientId;
options.AdminRealm = configuration["KEYCLOAK_ADMIN_REALM"] ?? options.AdminRealm;
options.AdminClientId = configuration["KEYCLOAK_ADMIN_CLIENT_ID"] ?? options.AdminClientId;
options.AdminUsername = configuration["KEYCLOAK_ADMIN_USERNAME"] ?? options.AdminUsername;
options.AdminPassword = configuration["KEYCLOAK_ADMIN_PASSWORD"] ?? options.AdminPassword;
});
builder.Services.AddOptions<SiteDataOptions>()
.Configure<IConfiguration>((options, configuration) =>
{
options.Host = configuration["SITE_DB_HOST"] ?? options.Host;
options.Database = configuration["SITE_DB_NAME"] ?? options.Database;
options.Username = configuration["SITE_DB_USER"] ?? options.Username;
options.Password = configuration["SITE_DB_PASSWORD"] ?? options.Password;
if (int.TryParse(configuration["SITE_DB_PORT"], out var port) && port > 0)
{
options.Port = port;
}
});
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "chesscubing.auth";
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.MaxAge = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
},
OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
});
builder.Services.AddSignalR();
builder.Services.AddHttpClient<KeycloakAuthService>();
builder.Services.AddSingleton<MySqlUserProfileStore>();
builder.Services.AddSingleton<MySqlSocialStore>();
builder.Services.AddSingleton<MySqlPlayerStatsStore>();
builder.Services.AddSingleton<ConnectedUserTracker>();
builder.Services.AddSingleton<PlayInviteCoordinator>();
builder.Services.AddSingleton<CollaborativeMatchCoordinator>();
var app = builder.Build();
await using (var scope = app.Services.CreateAsyncScope())
{
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
var socialStore = scope.ServiceProvider.GetRequiredService<MySqlSocialStore>();
var statsStore = scope.ServiceProvider.GetRequiredService<MySqlPlayerStatsStore>();
await profileStore.InitializeAsync(CancellationToken.None);
await socialStore.InitializeAsync(CancellationToken.None);
await statsStore.InitializeAsync(CancellationToken.None);
}
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/api/health", () => TypedResults.Ok(new { status = "ok" }));
app.MapGet("/api/auth/session", (ClaimsPrincipal user) =>
TypedResults.Ok(AuthSessionResponse.FromUser(user)));
app.MapGet("/api/users/me", async Task<IResult> (
ClaimsPrincipal user,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
var profile = await profileStore.GetOrCreateAsync(siteUser, cancellationToken);
return TypedResults.Ok(profile);
}).RequireAuthorization();
app.MapPut("/api/users/me", async Task<IResult> (
UpdateUserProfileRequest request,
ClaimsPrincipal user,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var profile = await profileStore.UpdateAsync(siteUser, request, cancellationToken);
return TypedResults.Ok(profile);
}
catch (UserProfileValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
}).RequireAuthorization();
app.MapGet("/api/users/me/stats", async Task<IResult> (
ClaimsPrincipal user,
MySqlPlayerStatsStore statsStore,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
var stats = await statsStore.GetUserStatsAsync(siteUser.Subject, cancellationToken);
return TypedResults.Ok(stats);
}).RequireAuthorization();
app.MapPost("/api/users/me/stats/matches", async Task<IResult> (
ReportCompletedMatchRequest request,
ClaimsPrincipal user,
MySqlPlayerStatsStore statsStore,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var result = await statsStore.RecordCompletedMatchAsync(siteUser, request, cancellationToken);
return TypedResults.Ok(result);
}
catch (PlayerStatsValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
}).RequireAuthorization();
var socialGroup = app.MapGroup("/api/social")
.RequireAuthorization();
socialGroup.MapGet("/overview", async Task<IResult> (
ClaimsPrincipal user,
MySqlSocialStore socialStore,
ConnectedUserTracker tracker,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
var overview = await socialStore.GetOverviewAsync(siteUser.Subject, tracker.IsOnline, cancellationToken);
return TypedResults.Ok(overview);
});
socialGroup.MapGet("/search", async Task<IResult> (
string? query,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
ConnectedUserTracker tracker,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var results = await socialStore.SearchUsersAsync(siteUser.Subject, query, tracker.IsOnline, cancellationToken);
return TypedResults.Ok(results);
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapPost("/invitations", async Task<IResult> (
SendFriendInvitationRequest request,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var targetSubject = request.TargetSubject?.Trim() ?? string.Empty;
await socialStore.SendInvitationAsync(siteUser.Subject, targetSubject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, targetSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapPost("/invitations/{invitationId:long}/accept", async Task<IResult> (
long invitationId,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var senderSubject = await socialStore.AcceptInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, senderSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapPost("/invitations/{invitationId:long}/decline", async Task<IResult> (
long invitationId,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var senderSubject = await socialStore.DeclineInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, senderSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapDelete("/invitations/{invitationId:long}", async Task<IResult> (
long invitationId,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var recipientSubject = await socialStore.CancelInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, recipientSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapDelete("/friends/{friendSubject}", async Task<IResult> (
string friendSubject,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
var normalizedFriendSubject = friendSubject.Trim();
await socialStore.RemoveFriendAsync(siteUser.Subject, normalizedFriendSubject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, normalizedFriendSubject);
return TypedResults.NoContent();
});
var adminGroup = app.MapGroup("/api/admin")
.RequireAuthorization("AdminOnly");
adminGroup.MapGet("/users", async Task<IResult> (
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
var identityUsersTask = keycloak.GetAdminUsersAsync(cancellationToken);
var siteProfilesTask = profileStore.ListAsync(cancellationToken);
await Task.WhenAll(identityUsersTask, siteProfilesTask);
var siteProfilesBySubject = (await siteProfilesTask)
.ToDictionary(profile => profile.Subject, StringComparer.Ordinal);
var users = (await identityUsersTask)
.Select(identity => MapAdminSummary(identity, siteProfilesBySubject.GetValueOrDefault(identity.Subject)))
.OrderByDescending(user => user.SiteProfileUpdatedUtc ?? user.AccountCreatedUtc ?? DateTime.MinValue)
.ThenBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
.ToArray();
return TypedResults.Ok(users);
});
adminGroup.MapPost("/users", async Task<IResult> (
AdminCreateUserRequest request,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
try
{
var normalized = NormalizeAdminCreate(request);
var fallbackDisplayName = BuildIdentityDisplayNameFromParts(normalized.FirstName, normalized.LastName, normalized.Username);
var siteProfileRequest = new UpdateUserProfileRequest
{
DisplayName = request.DisplayName,
Club = request.Club,
City = request.City,
PreferredFormat = request.PreferredFormat,
FavoriteCube = request.FavoriteCube,
Bio = request.Bio,
};
profileStore.ValidateAdminUpdate(fallbackDisplayName, siteProfileRequest);
var createdIdentity = await keycloak.CreateAdminUserAsync(
new AdminIdentityUserCreateRequest(
normalized.Username,
normalized.Email,
normalized.Password,
normalized.FirstName,
normalized.LastName,
normalized.IsEnabled,
normalized.IsEmailVerified),
cancellationToken);
var createdProfile = await profileStore.AdminUpsertAsync(
createdIdentity.Subject,
createdIdentity.Username,
createdIdentity.Email,
BuildIdentityDisplayName(createdIdentity),
siteProfileRequest,
cancellationToken);
return TypedResults.Created($"/api/admin/users/{Uri.EscapeDataString(createdIdentity.Subject)}", MapAdminDetail(createdIdentity, createdProfile));
}
catch (AdminUserValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
catch (UserProfileValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
adminGroup.MapGet("/users/{subject}", async Task<IResult> (
string subject,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
try
{
var identityTask = keycloak.GetAdminUserAsync(subject, cancellationToken);
var profileTask = profileStore.FindBySubjectAsync(subject, cancellationToken);
await Task.WhenAll(identityTask, profileTask);
return TypedResults.Ok(MapAdminDetail(await identityTask, await profileTask));
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
adminGroup.MapDelete("/users/{subject}", async Task<IResult> (
string subject,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
MySqlSocialStore socialStore,
CancellationToken cancellationToken) =>
{
try
{
await keycloak.DeleteAdminUserAsync(subject, cancellationToken);
await socialStore.DeleteUserAsync(subject, cancellationToken);
await profileStore.DeleteAsync(subject, cancellationToken);
return TypedResults.NoContent();
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
adminGroup.MapPut("/users/{subject}", async Task<IResult> (
string subject,
AdminUpdateUserRequest request,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
try
{
var existingIdentity = await keycloak.GetAdminUserAsync(subject, cancellationToken);
var normalized = NormalizeAdminUpdate(request);
var fallbackDisplayName = BuildIdentityDisplayNameFromParts(normalized.FirstName, normalized.LastName, existingIdentity.Username);
var siteProfileRequest = new UpdateUserProfileRequest
{
DisplayName = request.DisplayName,
Club = request.Club,
City = request.City,
PreferredFormat = request.PreferredFormat,
FavoriteCube = request.FavoriteCube,
Bio = request.Bio,
};
profileStore.ValidateAdminUpdate(fallbackDisplayName, siteProfileRequest);
var updatedIdentity = await keycloak.UpdateAdminUserAsync(
subject,
new AdminIdentityUserUpdateRequest(
existingIdentity.Username,
normalized.Email,
normalized.FirstName,
normalized.LastName,
normalized.IsEnabled,
normalized.IsEmailVerified),
cancellationToken);
var updatedProfile = await profileStore.AdminUpsertAsync(
updatedIdentity.Subject,
updatedIdentity.Username,
updatedIdentity.Email,
BuildIdentityDisplayName(updatedIdentity),
siteProfileRequest,
cancellationToken);
return TypedResults.Ok(MapAdminDetail(updatedIdentity, updatedProfile));
}
catch (AdminUserValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
catch (UserProfileValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
app.MapPost("/api/auth/login", async Task<IResult> (
LoginRequest request,
HttpContext httpContext,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return TypedResults.BadRequest(new ApiErrorResponse("Nom d'utilisateur et mot de passe obligatoires."));
}
try
{
var userInfo = await keycloak.LoginAsync(request.Username.Trim(), request.Password, cancellationToken);
await SignInAsync(httpContext, userInfo);
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
app.MapPost("/api/auth/register", async Task<IResult> (
RegisterRequest request,
HttpContext httpContext,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) ||
string.IsNullOrWhiteSpace(request.Email) ||
string.IsNullOrWhiteSpace(request.Password))
{
return TypedResults.BadRequest(new ApiErrorResponse("Nom d'utilisateur, email et mot de passe obligatoires."));
}
if (!string.Equals(request.Password, request.ConfirmPassword, StringComparison.Ordinal))
{
return TypedResults.BadRequest(new ApiErrorResponse("Les mots de passe ne correspondent pas."));
}
try
{
var userInfo = await keycloak.RegisterAsync(request with
{
Username = request.Username.Trim(),
Email = request.Email.Trim(),
FirstName = request.FirstName?.Trim(),
LastName = request.LastName?.Trim(),
}, cancellationToken);
await SignInAsync(httpContext, userInfo);
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
app.MapPost("/api/auth/logout", async Task<IResult> (HttpContext httpContext) =>
{
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return TypedResults.Ok(AuthSessionResponse.FromUser(new ClaimsPrincipal(new ClaimsIdentity())));
});
app.MapGet("/api/auth/logout/browser", async Task<IResult> (HttpContext httpContext) =>
{
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return TypedResults.Redirect("/index.html");
});
app.MapHub<SocialHub>("/hubs/social")
.RequireAuthorization();
app.Run();
static AdminUserSummaryResponse MapAdminSummary(AdminIdentityUser identity, UserProfileResponse? profile)
=> new()
{
Subject = identity.Subject,
Username = identity.Username,
Email = identity.Email,
IdentityDisplayName = BuildIdentityDisplayName(identity),
SiteDisplayName = profile?.DisplayName,
IsEnabled = identity.IsEnabled,
IsEmailVerified = identity.IsEmailVerified,
HasSiteProfile = profile is not null,
Club = profile?.Club,
City = profile?.City,
PreferredFormat = profile?.PreferredFormat,
AccountCreatedUtc = identity.CreatedUtc,
SiteProfileUpdatedUtc = profile?.UpdatedUtc,
};
static AdminUserDetailResponse MapAdminDetail(AdminIdentityUser identity, UserProfileResponse? profile)
{
var identityDisplayName = BuildIdentityDisplayName(identity);
return new AdminUserDetailResponse
{
Subject = identity.Subject,
Username = identity.Username,
Email = identity.Email,
FirstName = identity.FirstName,
LastName = identity.LastName,
IdentityDisplayName = identityDisplayName,
IsEnabled = identity.IsEnabled,
IsEmailVerified = identity.IsEmailVerified,
AccountCreatedUtc = identity.CreatedUtc,
HasSiteProfile = profile is not null,
DisplayName = profile?.DisplayName ?? identityDisplayName,
Club = profile?.Club,
City = profile?.City,
PreferredFormat = profile?.PreferredFormat,
FavoriteCube = profile?.FavoriteCube,
Bio = profile?.Bio,
SiteProfileCreatedUtc = profile?.CreatedUtc,
SiteProfileUpdatedUtc = profile?.UpdatedUtc,
};
}
static NormalizedAdminUserUpdate NormalizeAdminUpdate(AdminUpdateUserRequest request)
{
var email = NormalizeEmail(request.Email);
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
return new NormalizedAdminUserUpdate(
email,
firstName,
lastName,
request.IsEnabled,
request.IsEmailVerified);
}
static NormalizedAdminCreateUser NormalizeAdminCreate(AdminCreateUserRequest request)
{
var username = NormalizeRequiredValue(request.Username, "nom d'utilisateur", 120);
var email = NormalizeEmail(request.Email);
var password = NormalizePassword(request.Password, request.ConfirmPassword);
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
return new NormalizedAdminCreateUser(
username,
email,
password,
firstName,
lastName,
request.IsEnabled,
request.IsEmailVerified);
}
static string BuildIdentityDisplayName(AdminIdentityUser identity)
=> BuildIdentityDisplayNameFromParts(identity.FirstName, identity.LastName, identity.Username);
static string BuildIdentityDisplayNameFromParts(string? firstName, string? lastName, string username)
{
var fullName = string.Join(' ', new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value)));
return string.IsNullOrWhiteSpace(fullName)
? username
: fullName;
}
static string NormalizeRequiredValue(string? value, string fieldName, int maxLength)
{
var normalized = NormalizeOptionalValue(value, fieldName, maxLength);
return normalized ?? throw new AdminUserValidationException($"Le champ {fieldName} est obligatoire.");
}
static string? NormalizeEmail(string? value)
{
var normalized = NormalizeOptionalValue(value, "email", 255);
if (normalized is null)
{
return null;
}
try
{
_ = new MailAddress(normalized);
return normalized;
}
catch (FormatException)
{
throw new AdminUserValidationException("L'email n'est pas valide.");
}
}
static string NormalizePassword(string? password, string? confirmPassword)
{
if (string.IsNullOrWhiteSpace(password))
{
throw new AdminUserValidationException("Le mot de passe est obligatoire.");
}
if (!string.Equals(password, confirmPassword, StringComparison.Ordinal))
{
throw new AdminUserValidationException("Les mots de passe ne correspondent pas.");
}
if (password.Length < 8)
{
throw new AdminUserValidationException("Le mot de passe doit contenir au moins 8 caracteres.");
}
return password;
}
static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
{
var trimmed = value?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
if (trimmed.Length > maxLength)
{
throw new AdminUserValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
}
return trimmed;
}
static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo)
{
var issuedAt = DateTimeOffset.UtcNow;
var claims = new List<Claim>();
if (!string.IsNullOrWhiteSpace(userInfo.Subject))
{
claims.Add(new Claim("sub", userInfo.Subject));
claims.Add(new Claim(ClaimTypes.NameIdentifier, userInfo.Subject));
}
if (!string.IsNullOrWhiteSpace(userInfo.PreferredUsername))
{
claims.Add(new Claim("preferred_username", userInfo.PreferredUsername));
claims.Add(new Claim(ClaimTypes.Name, userInfo.PreferredUsername));
}
if (!string.IsNullOrWhiteSpace(userInfo.Name))
{
claims.Add(new Claim("name", userInfo.Name));
}
if (!string.IsNullOrWhiteSpace(userInfo.Email))
{
claims.Add(new Claim("email", userInfo.Email));
claims.Add(new Claim(ClaimTypes.Email, userInfo.Email));
}
foreach (var role in userInfo.Roles.Where(role => !string.IsNullOrWhiteSpace(role)))
{
claims.Add(new Claim("role", role));
claims.Add(new Claim(ClaimTypes.Role, role));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = true,
AllowRefresh = true,
IssuedUtc = issuedAt,
ExpiresUtc = issuedAt.AddDays(30),
});
httpContext.User = principal;
}
static async Task EnsureSiteUserAsync(
MySqlUserProfileStore profileStore,
KeycloakUserInfo userInfo,
CancellationToken cancellationToken)
{
var siteUser = AuthenticatedSiteUserFactory.FromKeycloakUserInfo(userInfo);
if (siteUser is null)
{
return;
}
await profileStore.GetOrCreateAsync(siteUser, cancellationToken);
}
static Task NotifySocialChangedAsync(IHubContext<SocialHub> hubContext, params string[] subjects)
{
var distinctSubjects = subjects
.Where(subject => !string.IsNullOrWhiteSpace(subject))
.Distinct(StringComparer.Ordinal)
.ToArray();
return distinctSubjects.Length == 0
? Task.CompletedTask
: hubContext.Clients.Users(distinctSubjects).SendAsync("SocialChanged");
}
sealed record NormalizedAdminUserUpdate(
string? Email,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified);
sealed record NormalizedAdminCreateUser(
string Username,
string? Email,
string Password,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified);

View File

@@ -0,0 +1,89 @@
namespace ChessCubing.Server.Social;
public sealed class CollaborativeMatchCoordinator
{
private readonly object _sync = new();
private readonly Dictionary<string, CollaborativeSessionState> _sessions = new(StringComparer.Ordinal);
public void RegisterSession(PlaySessionResponse session)
{
lock (_sync)
{
_sessions[session.SessionId] = new CollaborativeSessionState(
session.SessionId,
new HashSet<string>(StringComparer.Ordinal)
{
session.WhiteSubject,
session.BlackSubject,
session.InitiatorSubject,
session.RecipientSubject,
},
null,
0);
}
}
public bool CanAccessSession(string sessionId, string subject)
{
lock (_sync)
{
return _sessions.TryGetValue(sessionId, out var session)
&& session.ParticipantSubjects.Contains(subject);
}
}
public CollaborativeMatchStateMessage? GetLatestState(string sessionId, string subject)
{
lock (_sync)
{
if (!_sessions.TryGetValue(sessionId, out var session) ||
!session.ParticipantSubjects.Contains(subject))
{
throw new SocialValidationException("Cette session de partie est introuvable ou n'est pas accessible.");
}
return session.LatestState;
}
}
public CollaborativeMatchStateMessage PublishState(
string sessionId,
string subject,
string? matchJson,
string route)
{
lock (_sync)
{
if (!_sessions.TryGetValue(sessionId, out var session) ||
!session.ParticipantSubjects.Contains(subject))
{
throw new SocialValidationException("Cette session de partie est introuvable ou n'est pas accessible.");
}
var revision = session.Revision + 1;
var message = new CollaborativeMatchStateMessage
{
SessionId = sessionId,
MatchJson = matchJson,
Route = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim(),
SenderSubject = subject,
Revision = revision,
UpdatedUtc = DateTime.UtcNow,
};
_sessions[sessionId] = session with
{
Revision = revision,
LatestState = message,
};
return message;
}
}
private sealed record CollaborativeSessionState(
string SessionId,
HashSet<string> ParticipantSubjects,
CollaborativeMatchStateMessage? LatestState,
long Revision);
}

View File

@@ -0,0 +1,60 @@
namespace ChessCubing.Server.Social;
public sealed class ConnectedUserTracker
{
private readonly object _sync = new();
private readonly Dictionary<string, string> _subjectByConnection = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _connectionsBySubject = new(StringComparer.Ordinal);
public bool TrackConnection(string connectionId, string subject)
{
lock (_sync)
{
_subjectByConnection[connectionId] = subject;
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
{
connections = new HashSet<string>(StringComparer.Ordinal);
_connectionsBySubject[subject] = connections;
}
var wasOffline = connections.Count == 0;
connections.Add(connectionId);
return wasOffline;
}
}
public (string? Subject, bool BecameOffline) RemoveConnection(string connectionId)
{
lock (_sync)
{
if (!_subjectByConnection.Remove(connectionId, out var subject))
{
return (null, false);
}
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
{
return (subject, false);
}
connections.Remove(connectionId);
if (connections.Count > 0)
{
return (subject, false);
}
_connectionsBySubject.Remove(subject);
return (subject, true);
}
}
public bool IsOnline(string subject)
{
lock (_sync)
{
return _connectionsBySubject.TryGetValue(subject, out var connections)
&& connections.Count > 0;
}
}
}

View File

@@ -0,0 +1,787 @@
using ChessCubing.Server.Data;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ChessCubing.Server.Social;
public sealed class MySqlSocialStore(
IOptions<SiteDataOptions> options,
ILogger<MySqlSocialStore> logger)
{
private const string CreateFriendshipsTableSql = """
CREATE TABLE IF NOT EXISTS social_friendships (
id BIGINT NOT NULL AUTO_INCREMENT,
subject_low VARCHAR(190) NOT NULL,
subject_high VARCHAR(190) NOT NULL,
created_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_social_friendships_pair (subject_low, subject_high),
KEY ix_social_friendships_low (subject_low),
KEY ix_social_friendships_high (subject_high)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string CreateInvitationsTableSql = """
CREATE TABLE IF NOT EXISTS social_friend_invitations (
id BIGINT NOT NULL AUTO_INCREMENT,
sender_subject VARCHAR(190) NOT NULL,
recipient_subject VARCHAR(190) NOT NULL,
created_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_social_friend_invitations_pair (sender_subject, recipient_subject),
KEY ix_social_friend_invitations_sender (sender_subject),
KEY ix_social_friend_invitations_recipient (recipient_subject)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string SelectOverviewFriendsSql = """
SELECT
u.subject,
u.username,
u.display_name,
u.email,
u.club,
u.city
FROM social_friendships f
INNER JOIN site_users u
ON u.subject = CASE
WHEN f.subject_low = @subject THEN f.subject_high
ELSE f.subject_low
END
WHERE f.subject_low = @subject OR f.subject_high = @subject
ORDER BY u.display_name ASC, u.username ASC;
""";
private const string SelectReceivedInvitationsSql = """
SELECT
i.id,
u.subject,
u.username,
u.display_name,
u.email,
i.created_utc
FROM social_friend_invitations i
INNER JOIN site_users u
ON u.subject = i.sender_subject
WHERE i.recipient_subject = @subject
ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC;
""";
private const string SelectSentInvitationsSql = """
SELECT
i.id,
u.subject,
u.username,
u.display_name,
u.email,
i.created_utc
FROM social_friend_invitations i
INNER JOIN site_users u
ON u.subject = i.recipient_subject
WHERE i.sender_subject = @subject
ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC;
""";
private const string SearchUsersTemplateSql = """
SELECT
u.subject,
u.username,
u.display_name,
u.email,
u.club,
u.city,
EXISTS(
SELECT 1
FROM social_friendships f
WHERE (f.subject_low = @subject AND f.subject_high = u.subject)
OR (f.subject_high = @subject AND f.subject_low = u.subject)
) AS is_friend,
EXISTS(
SELECT 1
FROM social_friend_invitations i
WHERE i.sender_subject = @subject
AND i.recipient_subject = u.subject
) AS has_sent_invitation,
EXISTS(
SELECT 1
FROM social_friend_invitations i
WHERE i.sender_subject = u.subject
AND i.recipient_subject = @subject
) AS has_received_invitation
FROM site_users u
WHERE u.subject <> @subject
AND (
u.username LIKE @pattern
OR u.display_name LIKE @pattern
OR COALESCE(u.email, '') LIKE @pattern
OR COALESCE(u.club, '') LIKE @pattern
OR COALESCE(u.city, '') LIKE @pattern
)
ORDER BY
is_friend DESC,
has_received_invitation DESC,
has_sent_invitation DESC,
u.display_name ASC,
u.username ASC
LIMIT @limit;
""";
private const string SelectRelevantPresenceSubjectsSql = """
SELECT participant_subject
FROM (
SELECT CASE
WHEN f.subject_low = @subject THEN f.subject_high
ELSE f.subject_low
END AS participant_subject
FROM social_friendships f
WHERE f.subject_low = @subject OR f.subject_high = @subject
UNION
SELECT i.sender_subject AS participant_subject
FROM social_friend_invitations i
WHERE i.recipient_subject = @subject
UNION
SELECT i.recipient_subject AS participant_subject
FROM social_friend_invitations i
WHERE i.sender_subject = @subject
) participants
WHERE participant_subject <> @subject;
""";
private const string SelectFriendSubjectsSql = """
SELECT CASE
WHEN f.subject_low = @subject THEN f.subject_high
ELSE f.subject_low
END AS friend_subject
FROM social_friendships f
WHERE f.subject_low = @subject OR f.subject_high = @subject;
""";
private const string SelectKnownUserSubjectSql = """
SELECT subject
FROM site_users
WHERE subject = @subject
LIMIT 1;
""";
private const string SelectInvitationBetweenSql = """
SELECT
id,
sender_subject,
recipient_subject
FROM social_friend_invitations
WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB)
OR (sender_subject = @subjectB AND recipient_subject = @subjectA)
LIMIT 1;
""";
private const string SelectInvitationForRecipientSql = """
SELECT
id,
sender_subject,
recipient_subject
FROM social_friend_invitations
WHERE id = @invitationId
AND recipient_subject = @recipientSubject
LIMIT 1;
""";
private const string SelectFriendshipExistsSql = """
SELECT 1
FROM social_friendships
WHERE subject_low = @subjectLow
AND subject_high = @subjectHigh
LIMIT 1;
""";
private const string InsertInvitationSql = """
INSERT INTO social_friend_invitations (
sender_subject,
recipient_subject,
created_utc
)
VALUES (
@senderSubject,
@recipientSubject,
@createdUtc
);
""";
private const string InsertFriendshipSql = """
INSERT IGNORE INTO social_friendships (
subject_low,
subject_high,
created_utc
)
VALUES (
@subjectLow,
@subjectHigh,
@createdUtc
);
""";
private const string DeleteInvitationByRecipientSql = """
DELETE FROM social_friend_invitations
WHERE id = @invitationId
AND recipient_subject = @recipientSubject;
""";
private const string DeleteInvitationBySenderSql = """
DELETE FROM social_friend_invitations
WHERE id = @invitationId
AND sender_subject = @senderSubject;
""";
private const string DeleteInvitationsBetweenSql = """
DELETE FROM social_friend_invitations
WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB)
OR (sender_subject = @subjectB AND recipient_subject = @subjectA);
""";
private const string DeleteFriendshipSql = """
DELETE FROM social_friendships
WHERE subject_low = @subjectLow
AND subject_high = @subjectHigh;
""";
private const string DeleteUserFriendshipsSql = """
DELETE FROM social_friendships
WHERE subject_low = @subject
OR subject_high = @subject;
""";
private const string DeleteUserInvitationsSql = """
DELETE FROM social_friend_invitations
WHERE sender_subject = @subject
OR recipient_subject = @subject;
""";
private readonly SiteDataOptions _options = options.Value;
private readonly ILogger<MySqlSocialStore> _logger = logger;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
{
try
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await CreateSchemaAsync(connection, cancellationToken);
return;
}
catch (Exception exception) when (attempt < _options.InitializationRetries)
{
_logger.LogWarning(
exception,
"Initialisation MySQL impossible pour le module social (tentative {Attempt}/{MaxAttempts}).",
attempt,
_options.InitializationRetries);
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
}
}
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
await finalConnection.OpenAsync(cancellationToken);
await CreateSchemaAsync(finalConnection, cancellationToken);
}
public async Task<SocialOverviewResponse> GetOverviewAsync(
string subject,
Func<string, bool> isOnline,
CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
var friends = new List<SocialFriendResponse>();
await using (var command = connection.CreateCommand())
{
command.CommandText = SelectOverviewFriendsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var friendSubject = ReadString(reader, "subject");
friends.Add(new SocialFriendResponse
{
Subject = friendSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
Club = ReadNullableString(reader, "club"),
City = ReadNullableString(reader, "city"),
IsOnline = isOnline(friendSubject),
});
}
}
var receivedInvitations = new List<SocialInvitationResponse>();
await using (var command = connection.CreateCommand())
{
command.CommandText = SelectReceivedInvitationsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var senderSubject = ReadString(reader, "subject");
receivedInvitations.Add(new SocialInvitationResponse
{
InvitationId = ReadInt64(reader, "id"),
Subject = senderSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
CreatedUtc = ReadDateTime(reader, "created_utc"),
IsOnline = isOnline(senderSubject),
});
}
}
var sentInvitations = new List<SocialInvitationResponse>();
await using (var command = connection.CreateCommand())
{
command.CommandText = SelectSentInvitationsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var recipientSubject = ReadString(reader, "subject");
sentInvitations.Add(new SocialInvitationResponse
{
InvitationId = ReadInt64(reader, "id"),
Subject = recipientSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
CreatedUtc = ReadDateTime(reader, "created_utc"),
IsOnline = isOnline(recipientSubject),
});
}
}
return new SocialOverviewResponse
{
Friends = friends.ToArray(),
ReceivedInvitations = receivedInvitations.ToArray(),
SentInvitations = sentInvitations.ToArray(),
};
}
public async Task<IReadOnlyList<SocialSearchUserResponse>> SearchUsersAsync(
string subject,
string? query,
Func<string, bool> isOnline,
CancellationToken cancellationToken)
{
var normalizedQuery = NormalizeQuery(query);
if (normalizedQuery is null)
{
return [];
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SearchUsersTemplateSql;
command.Parameters.AddWithValue("@subject", subject);
command.Parameters.AddWithValue("@pattern", $"%{normalizedQuery}%");
command.Parameters.AddWithValue("@limit", 12);
var results = new List<SocialSearchUserResponse>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var foundSubject = ReadString(reader, "subject");
results.Add(new SocialSearchUserResponse
{
Subject = foundSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
Club = ReadNullableString(reader, "club"),
City = ReadNullableString(reader, "city"),
IsOnline = isOnline(foundSubject),
IsFriend = ReadBoolean(reader, "is_friend"),
HasSentInvitation = ReadBoolean(reader, "has_sent_invitation"),
HasReceivedInvitation = ReadBoolean(reader, "has_received_invitation"),
});
}
return results;
}
public async Task SendInvitationAsync(
string senderSubject,
string recipientSubject,
CancellationToken cancellationToken)
{
if (string.Equals(senderSubject, recipientSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Impossible de t'inviter toi-meme en ami.");
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
if (!await UserExistsAsync(connection, transaction, recipientSubject, cancellationToken))
{
throw new SocialValidationException("Le joueur cible est introuvable.");
}
var pair = NormalizePair(senderSubject, recipientSubject);
if (await FriendshipExistsAsync(connection, transaction, pair.SubjectLow, pair.SubjectHigh, cancellationToken))
{
throw new SocialValidationException("Ce joueur fait deja partie de tes amis.");
}
var invitation = await ReadInvitationBetweenAsync(connection, transaction, senderSubject, recipientSubject, cancellationToken);
if (invitation is not null)
{
throw new SocialValidationException(
string.Equals(invitation.SenderSubject, senderSubject, StringComparison.Ordinal)
? "Une invitation est deja en attente pour ce joueur."
: "Ce joueur t'a deja envoye une invitation. Accepte-la depuis la page utilisateur.");
}
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = InsertInvitationSql;
command.Parameters.AddWithValue("@senderSubject", senderSubject);
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
public async Task<string> AcceptInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var invitation = await ReadInvitationForRecipientAsync(connection, transaction, invitationId, recipientSubject, cancellationToken)
?? throw new SocialValidationException("Invitation introuvable ou deja traitee.");
var pair = NormalizePair(invitation.SenderSubject, invitation.RecipientSubject);
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = InsertFriendshipSql;
command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow);
command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh);
command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = DeleteInvitationsBetweenSql;
command.Parameters.AddWithValue("@subjectA", invitation.SenderSubject);
command.Parameters.AddWithValue("@subjectB", invitation.RecipientSubject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
return invitation.SenderSubject;
}
public async Task<string> DeclineInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
var invitation = await ReadInvitationForRecipientAsync(connection, transaction: null, invitationId, recipientSubject, cancellationToken)
?? throw new SocialValidationException("Invitation introuvable ou deja traitee.");
await using var command = connection.CreateCommand();
command.CommandText = DeleteInvitationByRecipientSql;
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
var deleted = await command.ExecuteNonQueryAsync(cancellationToken);
if (deleted <= 0)
{
throw new SocialValidationException("Invitation introuvable ou deja traitee.");
}
return invitation.SenderSubject;
}
public async Task<string> CancelInvitationAsync(long invitationId, string senderSubject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
var invitation = await ReadInvitationForSenderAsync(connection, transaction: null, invitationId, senderSubject, cancellationToken)
?? throw new SocialValidationException("Invitation introuvable ou deja retiree.");
await using var command = connection.CreateCommand();
command.CommandText = DeleteInvitationBySenderSql;
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@senderSubject", senderSubject);
var deleted = await command.ExecuteNonQueryAsync(cancellationToken);
if (deleted <= 0)
{
throw new SocialValidationException("Invitation introuvable ou deja retiree.");
}
return invitation.RecipientSubject;
}
public async Task RemoveFriendAsync(string subject, string friendSubject, CancellationToken cancellationToken)
{
var pair = NormalizePair(subject, friendSubject);
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = DeleteFriendshipSql;
command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow);
command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh);
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task<bool> AreFriendsAsync(string subject, string otherSubject, CancellationToken cancellationToken)
{
var pair = NormalizePair(subject, otherSubject);
await using var connection = await OpenConnectionAsync(cancellationToken);
return await FriendshipExistsAsync(connection, transaction: null, pair.SubjectLow, pair.SubjectHigh, cancellationToken);
}
public async Task<IReadOnlyList<string>> GetRelevantPresenceSubjectsAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectRelevantPresenceSubjectsSql;
command.Parameters.AddWithValue("@subject", subject);
var subjects = new List<string>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
subjects.Add(ReadString(reader, "participant_subject"));
}
return subjects;
}
public async Task<IReadOnlyList<string>> GetFriendSubjectsAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectFriendSubjectsSql;
command.Parameters.AddWithValue("@subject", subject);
var subjects = new List<string>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
subjects.Add(ReadString(reader, "friend_subject"));
}
return subjects;
}
public async Task DeleteUserAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using (var command = connection.CreateCommand())
{
command.CommandText = DeleteUserFriendshipsSql;
command.Parameters.AddWithValue("@subject", subject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.CommandText = DeleteUserInvitationsSql;
command.Parameters.AddWithValue("@subject", subject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
private async Task CreateSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
await using (var command = connection.CreateCommand())
{
command.CommandText = CreateFriendshipsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.CommandText = CreateInvitationsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
return connection;
}
private static (string SubjectLow, string SubjectHigh) NormalizePair(string subjectA, string subjectB)
=> string.CompareOrdinal(subjectA, subjectB) <= 0
? (subjectA, subjectB)
: (subjectB, subjectA);
private static string? NormalizeQuery(string? query)
{
var trimmed = query?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
if (trimmed.Length < 2)
{
throw new SocialValidationException("La recherche d'amis demande au moins 2 caracteres.");
}
return trimmed.Length > 80
? trimmed[..80]
: trimmed;
}
private static async Task<bool> UserExistsAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectKnownUserSubjectSql;
command.Parameters.AddWithValue("@subject", subject);
return await command.ExecuteScalarAsync(cancellationToken) is not null;
}
private static async Task<bool> FriendshipExistsAsync(
MySqlConnection connection,
MySqlTransaction? transaction,
string subjectLow,
string subjectHigh,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectFriendshipExistsSql;
command.Parameters.AddWithValue("@subjectLow", subjectLow);
command.Parameters.AddWithValue("@subjectHigh", subjectHigh);
return await command.ExecuteScalarAsync(cancellationToken) is not null;
}
private static async Task<InvitationRow?> ReadInvitationBetweenAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subjectA,
string subjectB,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectInvitationBetweenSql;
command.Parameters.AddWithValue("@subjectA", subjectA);
command.Parameters.AddWithValue("@subjectB", subjectB);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new InvitationRow(
ReadInt64(reader, "id"),
ReadString(reader, "sender_subject"),
ReadString(reader, "recipient_subject"));
}
private static async Task<InvitationRow?> ReadInvitationForRecipientAsync(
MySqlConnection connection,
MySqlTransaction? transaction,
long invitationId,
string recipientSubject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectInvitationForRecipientSql;
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new InvitationRow(
ReadInt64(reader, "id"),
ReadString(reader, "sender_subject"),
ReadString(reader, "recipient_subject"));
}
private static async Task<InvitationRow?> ReadInvitationForSenderAsync(
MySqlConnection connection,
MySqlTransaction? transaction,
long invitationId,
string senderSubject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
SELECT
id,
sender_subject,
recipient_subject
FROM social_friend_invitations
WHERE id = @invitationId
AND sender_subject = @senderSubject
LIMIT 1;
""";
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@senderSubject", senderSubject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new InvitationRow(
ReadInt64(reader, "id"),
ReadString(reader, "sender_subject"),
ReadString(reader, "recipient_subject"));
}
private static bool ReadBoolean(MySqlDataReader reader, string columnName)
=> Convert.ToInt32(reader[columnName]) > 0;
private static string ReadString(MySqlDataReader reader, string columnName)
=> reader.GetString(reader.GetOrdinal(columnName));
private static long ReadInt64(MySqlDataReader reader, string columnName)
=> reader.GetInt64(reader.GetOrdinal(columnName));
private static DateTime ReadDateTime(MySqlDataReader reader, string columnName)
=> reader.GetDateTime(reader.GetOrdinal(columnName));
private static string? ReadNullableString(MySqlDataReader reader, string columnName)
{
var ordinal = reader.GetOrdinal(columnName);
return reader.IsDBNull(ordinal)
? null
: reader.GetString(ordinal);
}
private sealed record InvitationRow(long Id, string SenderSubject, string RecipientSubject);
}

View File

@@ -0,0 +1,211 @@
namespace ChessCubing.Server.Social;
public sealed class PlayInviteCoordinator
{
private static readonly TimeSpan InviteLifetime = TimeSpan.FromMinutes(2);
private readonly object _sync = new();
private readonly Dictionary<string, PlayInviteState> _invitesById = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _inviteByParticipant = new(StringComparer.Ordinal);
public PlayInviteMessage CreateInvite(PlayInviteParticipant sender, PlayInviteParticipant recipient, string recipientColor)
{
var normalizedColor = NormalizeRecipientColor(recipientColor);
lock (_sync)
{
CleanupExpiredUnsafe();
if (_inviteByParticipant.ContainsKey(sender.Subject))
{
throw new SocialValidationException("Une invitation de partie est deja en cours pour ton compte.");
}
if (_inviteByParticipant.ContainsKey(recipient.Subject))
{
throw new SocialValidationException("Cet ami traite deja une autre invitation de partie.");
}
var nowUtc = DateTime.UtcNow;
var state = new PlayInviteState(
Guid.NewGuid().ToString("N"),
sender,
recipient,
normalizedColor,
nowUtc,
nowUtc.Add(InviteLifetime));
_invitesById[state.InviteId] = state;
_inviteByParticipant[sender.Subject] = state.InviteId;
_inviteByParticipant[recipient.Subject] = state.InviteId;
return MapInvite(state);
}
}
public PlayInviteCloseResult CancelInvite(string inviteId, string senderSubject)
{
lock (_sync)
{
var state = GetActiveInviteUnsafe(inviteId);
if (!string.Equals(state.Sender.Subject, senderSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Seul l'expediteur peut annuler cette invitation.");
}
RemoveInviteUnsafe(state);
return new PlayInviteCloseResult(
state.Sender.Subject,
state.Recipient.Subject,
new PlayInviteClosedMessage
{
InviteId = state.InviteId,
Reason = "cancelled",
Message = $"{state.Sender.DisplayName} a annule l'invitation de partie.",
});
}
}
public PlayInviteCloseResult DeclineInvite(string inviteId, string recipientSubject)
{
lock (_sync)
{
var state = GetActiveInviteUnsafe(inviteId);
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Seul le destinataire peut refuser cette invitation.");
}
RemoveInviteUnsafe(state);
return new PlayInviteCloseResult(
state.Sender.Subject,
state.Recipient.Subject,
new PlayInviteClosedMessage
{
InviteId = state.InviteId,
Reason = "declined",
Message = $"{state.Recipient.DisplayName} a refuse la partie.",
});
}
}
public PlayInviteAcceptResult AcceptInvite(string inviteId, string recipientSubject)
{
lock (_sync)
{
var state = GetActiveInviteUnsafe(inviteId);
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Seul le destinataire peut accepter cette invitation.");
}
RemoveInviteUnsafe(state);
var white = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
? state.Recipient
: state.Sender;
var black = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
? state.Sender
: state.Recipient;
var session = new PlaySessionResponse
{
SessionId = Guid.NewGuid().ToString("N"),
WhiteSubject = white.Subject,
WhiteName = white.DisplayName,
BlackSubject = black.Subject,
BlackName = black.DisplayName,
InitiatorSubject = state.Sender.Subject,
RecipientSubject = state.Recipient.Subject,
ConfirmedUtc = DateTime.UtcNow,
};
return new PlayInviteAcceptResult(state.Sender.Subject, state.Recipient.Subject, session);
}
}
private static string NormalizeRecipientColor(string recipientColor)
{
var normalized = recipientColor.Trim().ToLowerInvariant();
return normalized switch
{
"white" => "white",
"black" => "black",
_ => throw new SocialValidationException("La couleur demandee pour l'ami doit etre blanc ou noir."),
};
}
private static PlayInviteMessage MapInvite(PlayInviteState state)
=> new()
{
InviteId = state.InviteId,
SenderSubject = state.Sender.Subject,
SenderUsername = state.Sender.Username,
SenderDisplayName = state.Sender.DisplayName,
RecipientSubject = state.Recipient.Subject,
RecipientUsername = state.Recipient.Username,
RecipientDisplayName = state.Recipient.DisplayName,
RecipientColor = state.RecipientColor,
CreatedUtc = state.CreatedUtc,
ExpiresUtc = state.ExpiresUtc,
};
private PlayInviteState GetActiveInviteUnsafe(string inviteId)
{
CleanupExpiredUnsafe();
if (!_invitesById.TryGetValue(inviteId, out var state))
{
throw new SocialValidationException("Cette invitation de partie n'est plus disponible.");
}
return state;
}
private void CleanupExpiredUnsafe()
{
var nowUtc = DateTime.UtcNow;
var expiredInviteIds = _invitesById.Values
.Where(state => state.ExpiresUtc <= nowUtc)
.Select(state => state.InviteId)
.ToArray();
foreach (var expiredInviteId in expiredInviteIds)
{
if (_invitesById.TryGetValue(expiredInviteId, out var state))
{
RemoveInviteUnsafe(state);
}
}
}
private void RemoveInviteUnsafe(PlayInviteState state)
{
_invitesById.Remove(state.InviteId);
_inviteByParticipant.Remove(state.Sender.Subject);
_inviteByParticipant.Remove(state.Recipient.Subject);
}
private sealed record PlayInviteState(
string InviteId,
PlayInviteParticipant Sender,
PlayInviteParticipant Recipient,
string RecipientColor,
DateTime CreatedUtc,
DateTime ExpiresUtc);
}
public readonly record struct PlayInviteParticipant(
string Subject,
string Username,
string DisplayName);
public sealed record PlayInviteCloseResult(
string SenderSubject,
string RecipientSubject,
PlayInviteClosedMessage ClosedMessage);
public sealed record PlayInviteAcceptResult(
string SenderSubject,
string RecipientSubject,
PlaySessionResponse Session);

View File

@@ -0,0 +1,152 @@
namespace ChessCubing.Server.Social;
public sealed class SocialOverviewResponse
{
public SocialFriendResponse[] Friends { get; init; } = [];
public SocialInvitationResponse[] ReceivedInvitations { get; init; } = [];
public SocialInvitationResponse[] SentInvitations { get; init; } = [];
}
public sealed class SocialFriendResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public bool IsOnline { get; init; }
}
public sealed class SocialInvitationResponse
{
public long InvitationId { get; init; }
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public bool IsOnline { get; init; }
public DateTime CreatedUtc { get; init; }
}
public sealed class SocialSearchUserResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public bool IsOnline { get; init; }
public bool IsFriend { get; init; }
public bool HasSentInvitation { get; init; }
public bool HasReceivedInvitation { get; init; }
}
public sealed class SendFriendInvitationRequest
{
public string TargetSubject { get; init; } = string.Empty;
}
public sealed class PresenceSnapshotMessage
{
public string[] OnlineSubjects { get; init; } = [];
}
public sealed class PresenceChangedMessage
{
public string Subject { get; init; } = string.Empty;
public bool IsOnline { get; init; }
}
public sealed class PlayInviteMessage
{
public string InviteId { get; init; } = string.Empty;
public string SenderSubject { get; init; } = string.Empty;
public string SenderUsername { get; init; } = string.Empty;
public string SenderDisplayName { get; init; } = string.Empty;
public string RecipientSubject { get; init; } = string.Empty;
public string RecipientUsername { get; init; } = string.Empty;
public string RecipientDisplayName { get; init; } = string.Empty;
public string RecipientColor { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; }
public DateTime ExpiresUtc { get; init; }
}
public sealed class PlayInviteClosedMessage
{
public string InviteId { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
}
public sealed class PlaySessionResponse
{
public string SessionId { get; init; } = string.Empty;
public string WhiteSubject { get; init; } = string.Empty;
public string WhiteName { get; init; } = string.Empty;
public string BlackSubject { get; init; } = string.Empty;
public string BlackName { get; init; } = string.Empty;
public string InitiatorSubject { get; init; } = string.Empty;
public string RecipientSubject { get; init; } = string.Empty;
public DateTime ConfirmedUtc { get; init; }
}
public sealed class CollaborativeMatchStateMessage
{
public string SessionId { get; init; } = string.Empty;
public string? MatchJson { get; init; }
public string Route { get; init; } = "/application.html";
public string SenderSubject { get; init; } = string.Empty;
public long Revision { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed class SocialValidationException(string message) : Exception(message);

View File

@@ -0,0 +1,218 @@
using ChessCubing.Server.Users;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ChessCubing.Server.Social;
[Authorize]
public sealed class SocialHub(
ConnectedUserTracker tracker,
MySqlSocialStore socialStore,
MySqlUserProfileStore profileStore,
PlayInviteCoordinator playInviteCoordinator,
CollaborativeMatchCoordinator collaborativeMatchCoordinator) : Hub
{
private readonly ConnectedUserTracker _tracker = tracker;
private readonly MySqlSocialStore _socialStore = socialStore;
private readonly MySqlUserProfileStore _profileStore = profileStore;
private readonly PlayInviteCoordinator _playInviteCoordinator = playInviteCoordinator;
private readonly CollaborativeMatchCoordinator _collaborativeMatchCoordinator = collaborativeMatchCoordinator;
public override async Task OnConnectedAsync()
{
var siteUser = RequireCurrentUser();
var becameOnline = _tracker.TrackConnection(Context.ConnectionId, siteUser.Subject);
await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted);
if (becameOnline)
{
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(siteUser.Subject, Context.ConnectionAborted);
await NotifyPresenceChangedAsync(relevantSubjects, siteUser.Subject, isOnline: true);
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var (subject, becameOffline) = _tracker.RemoveConnection(Context.ConnectionId);
if (becameOffline && !string.IsNullOrWhiteSpace(subject))
{
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, CancellationToken.None);
await NotifyPresenceChangedAsync(relevantSubjects, subject, isOnline: false);
}
await base.OnDisconnectedAsync(exception);
}
public async Task RequestPresenceSnapshot()
{
var siteUser = RequireCurrentUser();
await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted);
}
public async Task<PlayInviteMessage> SendPlayInvite(string recipientSubject, string recipientColor)
{
var sender = RequireCurrentUser();
var normalizedRecipientSubject = recipientSubject.Trim();
if (!await _socialStore.AreFriendsAsync(sender.Subject, normalizedRecipientSubject, Context.ConnectionAborted))
{
throw new HubException("Seuls tes amis peuvent recevoir une invitation de partie.");
}
if (!_tracker.IsOnline(normalizedRecipientSubject))
{
throw new HubException("Cet ami n'est plus connecte pour le moment.");
}
var senderProfile = await _profileStore.GetOrCreateAsync(sender, Context.ConnectionAborted);
var recipientProfile = await _profileStore.FindBySubjectAsync(normalizedRecipientSubject, Context.ConnectionAborted);
if (recipientProfile is null)
{
throw new HubException("Le profil de cet ami est introuvable.");
}
try
{
var message = _playInviteCoordinator.CreateInvite(
new PlayInviteParticipant(sender.Subject, sender.Username, senderProfile.DisplayName),
new PlayInviteParticipant(recipientProfile.Subject, recipientProfile.Username, recipientProfile.DisplayName),
recipientColor);
await Clients.Users([sender.Subject, recipientProfile.Subject])
.SendAsync("PlayInviteUpdated", message, Context.ConnectionAborted);
return message;
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
public async Task RespondToPlayInvite(string inviteId, bool accept)
{
var recipient = RequireCurrentUser();
try
{
if (accept)
{
var accepted = _playInviteCoordinator.AcceptInvite(inviteId, recipient.Subject);
_collaborativeMatchCoordinator.RegisterSession(accepted.Session);
await Clients.Users([accepted.SenderSubject, accepted.RecipientSubject])
.SendAsync("PlayInviteAccepted", accepted.Session, Context.ConnectionAborted);
return;
}
var declined = _playInviteCoordinator.DeclineInvite(inviteId, recipient.Subject);
await Clients.Users([declined.SenderSubject, declined.RecipientSubject])
.SendAsync("PlayInviteClosed", declined.ClosedMessage, Context.ConnectionAborted);
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
public async Task CancelPlayInvite(string inviteId)
{
var sender = RequireCurrentUser();
try
{
var cancelled = _playInviteCoordinator.CancelInvite(inviteId, sender.Subject);
await Clients.Users([cancelled.SenderSubject, cancelled.RecipientSubject])
.SendAsync("PlayInviteClosed", cancelled.ClosedMessage, Context.ConnectionAborted);
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
public async Task<CollaborativeMatchStateMessage?> JoinPlaySession(string sessionId)
{
var siteUser = RequireCurrentUser();
var normalizedSessionId = sessionId.Trim();
try
{
var latestState = _collaborativeMatchCoordinator.GetLatestState(normalizedSessionId, siteUser.Subject);
await Groups.AddToGroupAsync(Context.ConnectionId, BuildSessionGroupName(normalizedSessionId), Context.ConnectionAborted);
return latestState;
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
public async Task LeavePlaySession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return;
}
await Groups.RemoveFromGroupAsync(Context.ConnectionId, BuildSessionGroupName(sessionId.Trim()), Context.ConnectionAborted);
}
public async Task PublishMatchState(string sessionId, string? matchJson, string route)
{
var siteUser = RequireCurrentUser();
var normalizedSessionId = sessionId.Trim();
try
{
var message = _collaborativeMatchCoordinator.PublishState(normalizedSessionId, siteUser.Subject, matchJson, route);
await Clients.GroupExcept(BuildSessionGroupName(normalizedSessionId), [Context.ConnectionId])
.SendAsync("CollaborativeMatchStateUpdated", message, Context.ConnectionAborted);
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
private AuthenticatedSiteUser RequireCurrentUser()
=> AuthenticatedSiteUserFactory.FromClaimsPrincipal(Context.User ?? new System.Security.Claims.ClaimsPrincipal())
?? throw new HubException("La session utilisateur est incomplete.");
private async Task SendPresenceSnapshotAsync(string subject, CancellationToken cancellationToken)
{
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, cancellationToken);
var onlineSubjects = relevantSubjects
.Where(_tracker.IsOnline)
.Distinct(StringComparer.Ordinal)
.ToArray();
await Clients.Caller.SendAsync(
"PresenceSnapshot",
new PresenceSnapshotMessage { OnlineSubjects = onlineSubjects },
cancellationToken);
}
private Task NotifyPresenceChangedAsync(IReadOnlyList<string> subjects, string changedSubject, bool isOnline)
{
var distinctSubjects = subjects
.Where(subject => !string.IsNullOrWhiteSpace(subject))
.Distinct(StringComparer.Ordinal)
.ToArray();
return distinctSubjects.Length == 0
? Task.CompletedTask
: Clients.Users(distinctSubjects).SendAsync(
"PresenceChanged",
new PresenceChangedMessage
{
Subject = changedSubject,
IsOnline = isOnline,
});
}
private static string BuildSessionGroupName(string sessionId)
=> $"play-session:{sessionId}";
}

View File

@@ -0,0 +1,992 @@
using ChessCubing.Server.Data;
using ChessCubing.Server.Users;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ChessCubing.Server.Stats;
public sealed class MySqlPlayerStatsStore(
IOptions<SiteDataOptions> options,
ILogger<MySqlPlayerStatsStore> logger)
{
private const string MatchResultWhite = "white";
private const string MatchResultBlack = "black";
private const string MatchResultStopped = "stopped";
private const int DefaultElo = 1200;
private const int EloKFactor = 32;
private const int RecentMatchLimit = 12;
private const string CreatePlayerStatsTableSql = """
CREATE TABLE IF NOT EXISTS site_player_stats (
subject VARCHAR(190) NOT NULL,
current_elo INT NOT NULL,
ranked_games INT NOT NULL,
casual_games INT NOT NULL,
wins INT NOT NULL,
losses INT NOT NULL,
stopped_games INT NOT NULL,
white_wins INT NOT NULL,
black_wins INT NOT NULL,
white_losses INT NOT NULL,
black_losses INT NOT NULL,
total_moves INT NOT NULL,
total_cube_rounds INT NOT NULL,
total_cube_entries INT NOT NULL,
total_cube_time_ms BIGINT NOT NULL,
best_cube_time_ms BIGINT NULL,
last_match_utc DATETIME(6) NULL,
updated_utc DATETIME(6) NOT NULL,
PRIMARY KEY (subject)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string CreateMatchResultsTableSql = """
CREATE TABLE IF NOT EXISTS site_match_results (
id BIGINT NOT NULL AUTO_INCREMENT,
match_id VARCHAR(80) NOT NULL,
collaboration_session_id VARCHAR(80) NULL,
recorded_by_subject VARCHAR(190) NOT NULL,
white_subject VARCHAR(190) NULL,
white_name VARCHAR(120) NOT NULL,
black_subject VARCHAR(190) NULL,
black_name VARCHAR(120) NOT NULL,
winner_subject VARCHAR(190) NULL,
result VARCHAR(20) NOT NULL,
mode VARCHAR(40) NOT NULL,
preset VARCHAR(40) NOT NULL,
match_label VARCHAR(120) NULL,
block_number INT NOT NULL,
white_moves INT NOT NULL,
black_moves INT NOT NULL,
cube_rounds INT NOT NULL,
white_best_cube_ms BIGINT NULL,
black_best_cube_ms BIGINT NULL,
white_average_cube_ms BIGINT NULL,
black_average_cube_ms BIGINT NULL,
is_ranked TINYINT(1) NOT NULL,
white_elo_before INT NULL,
white_elo_after INT NULL,
black_elo_before INT NULL,
black_elo_after INT NULL,
completed_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_site_match_results_match_id (match_id),
KEY idx_site_match_results_white_subject (white_subject, completed_utc),
KEY idx_site_match_results_black_subject (black_subject, completed_utc)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string EnsurePlayerStatsRowSql = """
INSERT INTO site_player_stats (
subject,
current_elo,
ranked_games,
casual_games,
wins,
losses,
stopped_games,
white_wins,
black_wins,
white_losses,
black_losses,
total_moves,
total_cube_rounds,
total_cube_entries,
total_cube_time_ms,
best_cube_time_ms,
last_match_utc,
updated_utc
)
VALUES (
@subject,
@currentElo,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
NULL,
NULL,
@updatedUtc
)
ON DUPLICATE KEY UPDATE
subject = VALUES(subject);
""";
private const string SelectPlayerStatsForUpdateSql = """
SELECT
subject,
current_elo,
ranked_games,
casual_games,
wins,
losses,
stopped_games,
white_wins,
black_wins,
white_losses,
black_losses,
total_moves,
total_cube_rounds,
total_cube_entries,
total_cube_time_ms,
best_cube_time_ms,
last_match_utc,
updated_utc
FROM site_player_stats
WHERE subject = @subject
LIMIT 1
FOR UPDATE;
""";
private const string SelectPlayerStatsSql = """
SELECT
subject,
current_elo,
ranked_games,
casual_games,
wins,
losses,
stopped_games,
white_wins,
black_wins,
white_losses,
black_losses,
total_moves,
total_cube_rounds,
total_cube_entries,
total_cube_time_ms,
best_cube_time_ms,
last_match_utc,
updated_utc
FROM site_player_stats
WHERE subject = @subject
LIMIT 1;
""";
private const string UpdatePlayerStatsSql = """
UPDATE site_player_stats
SET
current_elo = @currentElo,
ranked_games = @rankedGames,
casual_games = @casualGames,
wins = @wins,
losses = @losses,
stopped_games = @stoppedGames,
white_wins = @whiteWins,
black_wins = @blackWins,
white_losses = @whiteLosses,
black_losses = @blackLosses,
total_moves = @totalMoves,
total_cube_rounds = @totalCubeRounds,
total_cube_entries = @totalCubeEntries,
total_cube_time_ms = @totalCubeTimeMs,
best_cube_time_ms = @bestCubeTimeMs,
last_match_utc = @lastMatchUtc,
updated_utc = @updatedUtc
WHERE subject = @subject;
""";
private const string InsertMatchResultSql = """
INSERT INTO site_match_results (
match_id,
collaboration_session_id,
recorded_by_subject,
white_subject,
white_name,
black_subject,
black_name,
winner_subject,
result,
mode,
preset,
match_label,
block_number,
white_moves,
black_moves,
cube_rounds,
white_best_cube_ms,
black_best_cube_ms,
white_average_cube_ms,
black_average_cube_ms,
is_ranked,
white_elo_before,
white_elo_after,
black_elo_before,
black_elo_after,
completed_utc
)
VALUES (
@matchId,
@collaborationSessionId,
@recordedBySubject,
@whiteSubject,
@whiteName,
@blackSubject,
@blackName,
@winnerSubject,
@result,
@mode,
@preset,
@matchLabel,
@blockNumber,
@whiteMoves,
@blackMoves,
@cubeRounds,
@whiteBestCubeMs,
@blackBestCubeMs,
@whiteAverageCubeMs,
@blackAverageCubeMs,
@isRanked,
NULL,
NULL,
NULL,
NULL,
@completedUtc
);
""";
private const string UpdateMatchResultEloSql = """
UPDATE site_match_results
SET
white_elo_before = @whiteEloBefore,
white_elo_after = @whiteEloAfter,
black_elo_before = @blackEloBefore,
black_elo_after = @blackEloAfter
WHERE match_id = @matchId;
""";
private const string SelectRecentMatchesSql = """
SELECT
match_id,
white_subject,
white_name,
black_subject,
black_name,
result,
mode,
preset,
match_label,
white_moves,
black_moves,
cube_rounds,
white_best_cube_ms,
black_best_cube_ms,
white_average_cube_ms,
black_average_cube_ms,
is_ranked,
white_elo_before,
white_elo_after,
black_elo_before,
black_elo_after,
completed_utc
FROM site_match_results
WHERE white_subject = @subject OR black_subject = @subject
ORDER BY completed_utc DESC, id DESC
LIMIT @limit;
""";
private readonly SiteDataOptions _options = options.Value;
private readonly ILogger<MySqlPlayerStatsStore> _logger = logger;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
{
try
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await CreateSchemaAsync(connection, cancellationToken);
return;
}
catch (Exception exception) when (attempt < _options.InitializationRetries)
{
_logger.LogWarning(
exception,
"Initialisation MySQL impossible pour les statistiques joueurs (tentative {Attempt}/{MaxAttempts}).",
attempt,
_options.InitializationRetries);
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
}
}
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
await finalConnection.OpenAsync(cancellationToken);
await CreateSchemaAsync(finalConnection, cancellationToken);
}
public async Task<ReportCompletedMatchResponse> RecordCompletedMatchAsync(
AuthenticatedSiteUser reporter,
ReportCompletedMatchRequest request,
CancellationToken cancellationToken)
{
var normalized = NormalizeRequest(reporter, request);
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
if (!await TryInsertMatchReservationAsync(connection, transaction, normalized, cancellationToken))
{
await transaction.RollbackAsync(cancellationToken);
return new ReportCompletedMatchResponse
{
Recorded = true,
IsDuplicate = true,
IsRanked = normalized.IsRanked,
};
}
PlayerStatsRow? whiteStats = null;
PlayerStatsRow? blackStats = null;
EloSnapshot? elo = null;
if (normalized.WhiteSubject is not null)
{
await EnsurePlayerStatsRowAsync(connection, transaction, normalized.WhiteSubject, normalized.CompletedUtc, cancellationToken);
whiteStats = await ReadPlayerStatsForUpdateAsync(connection, transaction, normalized.WhiteSubject, cancellationToken);
}
if (normalized.BlackSubject is not null)
{
await EnsurePlayerStatsRowAsync(connection, transaction, normalized.BlackSubject, normalized.CompletedUtc, cancellationToken);
blackStats = await ReadPlayerStatsForUpdateAsync(connection, transaction, normalized.BlackSubject, cancellationToken);
}
if (normalized.IsRanked && whiteStats is not null && blackStats is not null)
{
elo = ComputeElo(whiteStats.CurrentElo, blackStats.CurrentElo, normalized.Result);
whiteStats = whiteStats with { CurrentElo = elo.WhiteAfter };
blackStats = blackStats with { CurrentElo = elo.BlackAfter };
}
if (whiteStats is not null)
{
whiteStats = ApplyMatchToStats(
whiteStats,
normalized,
MatchResultWhite,
normalized.WhiteMoves,
normalized.WhiteCubeTimes,
normalized.IsRanked,
elo?.WhiteAfter);
await UpdatePlayerStatsAsync(connection, transaction, whiteStats, cancellationToken);
}
if (blackStats is not null)
{
blackStats = ApplyMatchToStats(
blackStats,
normalized,
MatchResultBlack,
normalized.BlackMoves,
normalized.BlackCubeTimes,
normalized.IsRanked,
elo?.BlackAfter);
await UpdatePlayerStatsAsync(connection, transaction, blackStats, cancellationToken);
}
await UpdateMatchEloAsync(connection, transaction, normalized.MatchId, elo, cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new ReportCompletedMatchResponse
{
Recorded = true,
IsDuplicate = false,
IsRanked = normalized.IsRanked,
WhiteEloAfter = elo?.WhiteAfter,
BlackEloAfter = elo?.BlackAfter,
};
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
public async Task<UserStatsResponse> GetUserStatsAsync(string subject, CancellationToken cancellationToken)
{
var normalizedSubject = NormalizeRequiredValue(subject, "subject", 190);
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
var stats = await ReadPlayerStatsAsync(connection, normalizedSubject, cancellationToken);
var recentMatches = await ReadRecentMatchesAsync(connection, normalizedSubject, cancellationToken);
if (stats is null)
{
return new UserStatsResponse
{
Subject = normalizedSubject,
CurrentElo = DefaultElo,
RecentMatches = recentMatches,
};
}
return new UserStatsResponse
{
Subject = stats.Subject,
CurrentElo = stats.CurrentElo,
RankedGames = stats.RankedGames,
CasualGames = stats.CasualGames,
Wins = stats.Wins,
Losses = stats.Losses,
StoppedGames = stats.StoppedGames,
WhiteWins = stats.WhiteWins,
BlackWins = stats.BlackWins,
WhiteLosses = stats.WhiteLosses,
BlackLosses = stats.BlackLosses,
TotalMoves = stats.TotalMoves,
TotalCubeRounds = stats.TotalCubeRounds,
BestCubeTimeMs = stats.BestCubeTimeMs,
AverageCubeTimeMs = stats.TotalCubeEntries <= 0
? null
: (long?)Math.Round((double)stats.TotalCubeTimeMs / stats.TotalCubeEntries, MidpointRounding.AwayFromZero),
LastMatchUtc = stats.LastMatchUtc,
RecentMatches = recentMatches,
};
}
private static async Task CreateSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
await using (var command = connection.CreateCommand())
{
command.CommandText = CreatePlayerStatsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.CommandText = CreateMatchResultsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
private static async Task EnsurePlayerStatsRowAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subject,
DateTime nowUtc,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = EnsurePlayerStatsRowSql;
command.Parameters.AddWithValue("@subject", subject);
command.Parameters.AddWithValue("@currentElo", DefaultElo);
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<bool> TryInsertMatchReservationAsync(
MySqlConnection connection,
MySqlTransaction transaction,
NormalizedCompletedMatch normalized,
CancellationToken cancellationToken)
{
try
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = InsertMatchResultSql;
command.Parameters.AddWithValue("@matchId", normalized.MatchId);
command.Parameters.AddWithValue("@collaborationSessionId", (object?)normalized.CollaborationSessionId ?? DBNull.Value);
command.Parameters.AddWithValue("@recordedBySubject", normalized.RecordedBySubject);
command.Parameters.AddWithValue("@whiteSubject", (object?)normalized.WhiteSubject ?? DBNull.Value);
command.Parameters.AddWithValue("@whiteName", normalized.WhiteName);
command.Parameters.AddWithValue("@blackSubject", (object?)normalized.BlackSubject ?? DBNull.Value);
command.Parameters.AddWithValue("@blackName", normalized.BlackName);
command.Parameters.AddWithValue("@winnerSubject", (object?)normalized.WinnerSubject ?? DBNull.Value);
command.Parameters.AddWithValue("@result", normalized.Result);
command.Parameters.AddWithValue("@mode", normalized.Mode);
command.Parameters.AddWithValue("@preset", normalized.Preset);
command.Parameters.AddWithValue("@matchLabel", (object?)normalized.MatchLabel ?? DBNull.Value);
command.Parameters.AddWithValue("@blockNumber", normalized.BlockNumber);
command.Parameters.AddWithValue("@whiteMoves", normalized.WhiteMoves);
command.Parameters.AddWithValue("@blackMoves", normalized.BlackMoves);
command.Parameters.AddWithValue("@cubeRounds", normalized.CubeRounds.Length);
command.Parameters.AddWithValue("@whiteBestCubeMs", (object?)normalized.WhiteCubeTimes.BestMs ?? DBNull.Value);
command.Parameters.AddWithValue("@blackBestCubeMs", (object?)normalized.BlackCubeTimes.BestMs ?? DBNull.Value);
command.Parameters.AddWithValue("@whiteAverageCubeMs", (object?)normalized.WhiteCubeTimes.AverageMs ?? DBNull.Value);
command.Parameters.AddWithValue("@blackAverageCubeMs", (object?)normalized.BlackCubeTimes.AverageMs ?? DBNull.Value);
command.Parameters.AddWithValue("@isRanked", normalized.IsRanked);
command.Parameters.AddWithValue("@completedUtc", normalized.CompletedUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
return true;
}
catch (MySqlException exception) when (exception.Number == 1062)
{
return false;
}
}
private static async Task<PlayerStatsRow?> ReadPlayerStatsAsync(
MySqlConnection connection,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = SelectPlayerStatsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken)
? MapPlayerStats(reader)
: null;
}
private static async Task<PlayerStatsRow> ReadPlayerStatsForUpdateAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectPlayerStatsForUpdateSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
throw new InvalidOperationException("La ligne de statistiques joueur est introuvable.");
}
return MapPlayerStats(reader);
}
private static async Task UpdatePlayerStatsAsync(
MySqlConnection connection,
MySqlTransaction transaction,
PlayerStatsRow stats,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = UpdatePlayerStatsSql;
command.Parameters.AddWithValue("@subject", stats.Subject);
command.Parameters.AddWithValue("@currentElo", stats.CurrentElo);
command.Parameters.AddWithValue("@rankedGames", stats.RankedGames);
command.Parameters.AddWithValue("@casualGames", stats.CasualGames);
command.Parameters.AddWithValue("@wins", stats.Wins);
command.Parameters.AddWithValue("@losses", stats.Losses);
command.Parameters.AddWithValue("@stoppedGames", stats.StoppedGames);
command.Parameters.AddWithValue("@whiteWins", stats.WhiteWins);
command.Parameters.AddWithValue("@blackWins", stats.BlackWins);
command.Parameters.AddWithValue("@whiteLosses", stats.WhiteLosses);
command.Parameters.AddWithValue("@blackLosses", stats.BlackLosses);
command.Parameters.AddWithValue("@totalMoves", stats.TotalMoves);
command.Parameters.AddWithValue("@totalCubeRounds", stats.TotalCubeRounds);
command.Parameters.AddWithValue("@totalCubeEntries", stats.TotalCubeEntries);
command.Parameters.AddWithValue("@totalCubeTimeMs", stats.TotalCubeTimeMs);
command.Parameters.AddWithValue("@bestCubeTimeMs", (object?)stats.BestCubeTimeMs ?? DBNull.Value);
command.Parameters.AddWithValue("@lastMatchUtc", (object?)stats.LastMatchUtc ?? DBNull.Value);
command.Parameters.AddWithValue("@updatedUtc", stats.UpdatedUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task UpdateMatchEloAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string matchId,
EloSnapshot? elo,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = UpdateMatchResultEloSql;
command.Parameters.AddWithValue("@matchId", matchId);
command.Parameters.AddWithValue("@whiteEloBefore", (object?)elo?.WhiteBefore ?? DBNull.Value);
command.Parameters.AddWithValue("@whiteEloAfter", (object?)elo?.WhiteAfter ?? DBNull.Value);
command.Parameters.AddWithValue("@blackEloBefore", (object?)elo?.BlackBefore ?? DBNull.Value);
command.Parameters.AddWithValue("@blackEloAfter", (object?)elo?.BlackAfter ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<UserRecentMatchResponse[]> ReadRecentMatchesAsync(
MySqlConnection connection,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = SelectRecentMatchesSql;
command.Parameters.AddWithValue("@subject", subject);
command.Parameters.AddWithValue("@limit", RecentMatchLimit);
var matches = new List<UserRecentMatchResponse>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
matches.Add(MapRecentMatch(reader, subject));
}
return matches.ToArray();
}
private static PlayerStatsRow ApplyMatchToStats(
PlayerStatsRow current,
NormalizedCompletedMatch normalized,
string playerColor,
int playerMoves,
CubeTimeSummary cubeTimes,
bool isRanked,
int? eloAfter)
{
var isWhite = playerColor == MatchResultWhite;
var isWin = normalized.Result == playerColor;
var isLoss = normalized.Result is MatchResultWhite or MatchResultBlack && normalized.Result != playerColor;
var isStopped = normalized.Result == MatchResultStopped;
return current with
{
RankedGames = current.RankedGames + (isRanked ? 1 : 0),
CasualGames = current.CasualGames + (isRanked ? 0 : 1),
Wins = current.Wins + (isWin ? 1 : 0),
Losses = current.Losses + (isLoss ? 1 : 0),
StoppedGames = current.StoppedGames + (isStopped ? 1 : 0),
WhiteWins = current.WhiteWins + (isWhite && isWin ? 1 : 0),
BlackWins = current.BlackWins + (!isWhite && isWin ? 1 : 0),
WhiteLosses = current.WhiteLosses + (isWhite && isLoss ? 1 : 0),
BlackLosses = current.BlackLosses + (!isWhite && isLoss ? 1 : 0),
TotalMoves = current.TotalMoves + playerMoves,
TotalCubeRounds = current.TotalCubeRounds + normalized.CubeRounds.Length,
TotalCubeEntries = current.TotalCubeEntries + cubeTimes.Count,
TotalCubeTimeMs = current.TotalCubeTimeMs + cubeTimes.TotalMs,
BestCubeTimeMs = MinNullable(current.BestCubeTimeMs, cubeTimes.BestMs),
LastMatchUtc = normalized.CompletedUtc,
UpdatedUtc = normalized.CompletedUtc,
CurrentElo = eloAfter ?? current.CurrentElo,
};
}
private static EloSnapshot ComputeElo(int whiteRating, int blackRating, string result)
{
var whiteScore = result == MatchResultWhite ? 1d : 0d;
var expectedWhite = 1d / (1d + Math.Pow(10d, (blackRating - whiteRating) / 400d));
var whiteDelta = (int)Math.Round(EloKFactor * (whiteScore - expectedWhite), MidpointRounding.AwayFromZero);
return new EloSnapshot(
whiteRating,
whiteRating + whiteDelta,
blackRating,
blackRating - whiteDelta);
}
private static UserRecentMatchResponse MapRecentMatch(MySqlDataReader reader, string subject)
{
var whiteSubject = ReadNullableString(reader, "white_subject");
var blackSubject = ReadNullableString(reader, "black_subject");
var playerColor = string.Equals(whiteSubject, subject, StringComparison.Ordinal) ? MatchResultWhite : MatchResultBlack;
var isWhite = playerColor == MatchResultWhite;
var result = ReadString(reader, "result");
var isWin = result == playerColor;
var isLoss = result is MatchResultWhite or MatchResultBlack && result != playerColor;
var eloBefore = isWhite ? ReadNullableInt(reader, "white_elo_before") : ReadNullableInt(reader, "black_elo_before");
var eloAfter = isWhite ? ReadNullableInt(reader, "white_elo_after") : ReadNullableInt(reader, "black_elo_after");
return new UserRecentMatchResponse
{
MatchId = ReadString(reader, "match_id"),
CompletedUtc = ReadDateTime(reader, "completed_utc"),
Result = result,
Mode = ReadString(reader, "mode"),
Preset = ReadString(reader, "preset"),
MatchLabel = ReadNullableString(reader, "match_label"),
PlayerColor = playerColor,
PlayerName = isWhite ? ReadString(reader, "white_name") : ReadString(reader, "black_name"),
OpponentName = isWhite ? ReadString(reader, "black_name") : ReadString(reader, "white_name"),
OpponentSubject = isWhite ? blackSubject : whiteSubject,
IsRanked = ReadBoolean(reader, "is_ranked"),
IsWin = isWin,
IsLoss = isLoss,
PlayerMoves = isWhite ? ReadInt(reader, "white_moves") : ReadInt(reader, "black_moves"),
OpponentMoves = isWhite ? ReadInt(reader, "black_moves") : ReadInt(reader, "white_moves"),
CubeRounds = ReadInt(reader, "cube_rounds"),
PlayerBestCubeTimeMs = isWhite ? ReadNullableLong(reader, "white_best_cube_ms") : ReadNullableLong(reader, "black_best_cube_ms"),
PlayerAverageCubeTimeMs = isWhite ? ReadNullableLong(reader, "white_average_cube_ms") : ReadNullableLong(reader, "black_average_cube_ms"),
EloBefore = eloBefore,
EloAfter = eloAfter,
EloDelta = eloBefore is not null && eloAfter is not null ? eloAfter.Value - eloBefore.Value : null,
};
}
private static PlayerStatsRow MapPlayerStats(MySqlDataReader reader)
=> new(
ReadString(reader, "subject"),
ReadInt(reader, "current_elo"),
ReadInt(reader, "ranked_games"),
ReadInt(reader, "casual_games"),
ReadInt(reader, "wins"),
ReadInt(reader, "losses"),
ReadInt(reader, "stopped_games"),
ReadInt(reader, "white_wins"),
ReadInt(reader, "black_wins"),
ReadInt(reader, "white_losses"),
ReadInt(reader, "black_losses"),
ReadInt(reader, "total_moves"),
ReadInt(reader, "total_cube_rounds"),
ReadInt(reader, "total_cube_entries"),
ReadLong(reader, "total_cube_time_ms"),
ReadNullableLong(reader, "best_cube_time_ms"),
ReadNullableDateTime(reader, "last_match_utc"),
ReadDateTime(reader, "updated_utc"));
private static NormalizedCompletedMatch NormalizeRequest(AuthenticatedSiteUser reporter, ReportCompletedMatchRequest request)
{
var matchId = NormalizeRequiredValue(request.MatchId, "identifiant de match", 80);
var collaborationSessionId = NormalizeOptionalValue(request.CollaborationSessionId, "session collaborative", 80);
var whiteSubject = NormalizeOptionalValue(request.WhiteSubject, "subject blanc", 190);
var blackSubject = NormalizeOptionalValue(request.BlackSubject, "subject noir", 190);
var whiteName = NormalizeRequiredValue(request.WhiteName, "joueur blanc", 120);
var blackName = NormalizeRequiredValue(request.BlackName, "joueur noir", 120);
var mode = NormalizeRequiredValue(request.Mode, "mode", 40);
var preset = NormalizeRequiredValue(request.Preset, "preset", 40);
var matchLabel = NormalizeOptionalValue(request.MatchLabel, "nom de rencontre", 120);
var result = NormalizeResult(request.Result);
var blockNumber = Math.Clamp(request.BlockNumber, 1, 999);
var whiteMoves = Math.Clamp(request.WhiteMoves, 0, 9999);
var blackMoves = Math.Clamp(request.BlackMoves, 0, 9999);
if (whiteSubject is null && blackSubject is null)
{
throw new PlayerStatsValidationException("Impossible d'enregistrer une partie sans joueur identifie.");
}
if (whiteSubject is not null &&
blackSubject is not null &&
string.Equals(whiteSubject, blackSubject, StringComparison.Ordinal))
{
throw new PlayerStatsValidationException("Les deux cotes ne peuvent pas pointer vers le meme compte.");
}
if (!string.Equals(reporter.Subject, whiteSubject, StringComparison.Ordinal) &&
!string.Equals(reporter.Subject, blackSubject, StringComparison.Ordinal))
{
throw new PlayerStatsValidationException("Le compte connecte doit correspondre a l'un des deux joueurs pour enregistrer la partie.");
}
var cubeRounds = (request.CubeRounds ?? [])
.Take(64)
.Select(round => new NormalizedCubeRound(
Math.Clamp(round.BlockNumber, 1, 999),
round.Number is null ? null : Math.Clamp(round.Number.Value, 1, 999),
NormalizeCubeDuration(round.White),
NormalizeCubeDuration(round.Black)))
.ToArray();
var whiteCubeTimes = SummarizeCubeTimes(cubeRounds.Select(round => round.White));
var blackCubeTimes = SummarizeCubeTimes(cubeRounds.Select(round => round.Black));
var isRanked = whiteSubject is not null &&
blackSubject is not null &&
result is MatchResultWhite or MatchResultBlack;
return new NormalizedCompletedMatch(
matchId,
collaborationSessionId,
reporter.Subject,
whiteSubject,
whiteName,
blackSubject,
blackName,
result,
mode,
preset,
matchLabel,
blockNumber,
whiteMoves,
blackMoves,
cubeRounds,
whiteCubeTimes,
blackCubeTimes,
isRanked,
result == MatchResultWhite
? whiteSubject
: result == MatchResultBlack
? blackSubject
: null,
DateTime.UtcNow);
}
private static CubeTimeSummary SummarizeCubeTimes(IEnumerable<long?> values)
{
var normalized = values
.Where(value => value is > 0)
.Select(value => value!.Value)
.ToArray();
if (normalized.Length == 0)
{
return new CubeTimeSummary(0, 0, null, null);
}
return new CubeTimeSummary(
normalized.Length,
normalized.Sum(),
normalized.Min(),
(long)Math.Round(normalized.Average(), MidpointRounding.AwayFromZero));
}
private static long? NormalizeCubeDuration(long? value)
=> value is > 0
? Math.Clamp(value.Value, 1, 3_600_000)
: null;
private static string NormalizeResult(string? value)
{
var normalized = NormalizeRequiredValue(value, "resultat", 20).ToLowerInvariant();
return normalized switch
{
MatchResultWhite => MatchResultWhite,
MatchResultBlack => MatchResultBlack,
MatchResultStopped => MatchResultStopped,
_ => throw new PlayerStatsValidationException("Le resultat doit etre white, black ou stopped."),
};
}
private static string NormalizeRequiredValue(string? value, string fieldName, int maxLength)
=> NormalizeOptionalValue(value, fieldName, maxLength)
?? throw new PlayerStatsValidationException($"Le champ {fieldName} est obligatoire.");
private static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
{
var trimmed = value?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
if (trimmed.Length > maxLength)
{
throw new PlayerStatsValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
}
return trimmed;
}
private static long? MinNullable(long? current, long? candidate)
{
if (candidate is null)
{
return current;
}
return current is null
? candidate
: Math.Min(current.Value, candidate.Value);
}
private static string ReadString(MySqlDataReader reader, string column)
=> reader.GetString(reader.GetOrdinal(column));
private static string? ReadNullableString(MySqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
}
private static int ReadInt(MySqlDataReader reader, string column)
=> reader.GetInt32(reader.GetOrdinal(column));
private static int? ReadNullableInt(MySqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
}
private static long ReadLong(MySqlDataReader reader, string column)
=> reader.GetInt64(reader.GetOrdinal(column));
private static long? ReadNullableLong(MySqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetInt64(ordinal);
}
private static bool ReadBoolean(MySqlDataReader reader, string column)
=> reader.GetBoolean(reader.GetOrdinal(column));
private static DateTime ReadDateTime(MySqlDataReader reader, string column)
=> reader.GetDateTime(reader.GetOrdinal(column));
private static DateTime? ReadNullableDateTime(MySqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetDateTime(ordinal);
}
private sealed record PlayerStatsRow(
string Subject,
int CurrentElo,
int RankedGames,
int CasualGames,
int Wins,
int Losses,
int StoppedGames,
int WhiteWins,
int BlackWins,
int WhiteLosses,
int BlackLosses,
int TotalMoves,
int TotalCubeRounds,
int TotalCubeEntries,
long TotalCubeTimeMs,
long? BestCubeTimeMs,
DateTime? LastMatchUtc,
DateTime UpdatedUtc);
private sealed record EloSnapshot(
int WhiteBefore,
int WhiteAfter,
int BlackBefore,
int BlackAfter);
private sealed record NormalizedCubeRound(
int BlockNumber,
int? Number,
long? White,
long? Black);
private sealed record CubeTimeSummary(
int Count,
long TotalMs,
long? BestMs,
long? AverageMs);
private sealed record NormalizedCompletedMatch(
string MatchId,
string? CollaborationSessionId,
string RecordedBySubject,
string? WhiteSubject,
string WhiteName,
string? BlackSubject,
string BlackName,
string Result,
string Mode,
string Preset,
string? MatchLabel,
int BlockNumber,
int WhiteMoves,
int BlackMoves,
NormalizedCubeRound[] CubeRounds,
CubeTimeSummary WhiteCubeTimes,
CubeTimeSummary BlackCubeTimes,
bool IsRanked,
string? WinnerSubject,
DateTime CompletedUtc);
}

View File

@@ -0,0 +1,140 @@
namespace ChessCubing.Server.Stats;
public sealed class ReportCompletedMatchRequest
{
public string MatchId { get; init; } = string.Empty;
public string? CollaborationSessionId { get; init; }
public string? WhiteSubject { get; init; }
public string WhiteName { get; init; } = string.Empty;
public string? BlackSubject { get; init; }
public string BlackName { get; init; } = string.Empty;
public string Result { get; init; } = string.Empty;
public string Mode { get; init; } = string.Empty;
public string Preset { get; init; } = string.Empty;
public string? MatchLabel { get; init; }
public int BlockNumber { get; init; }
public int WhiteMoves { get; init; }
public int BlackMoves { get; init; }
public ReportCompletedCubeRound[] CubeRounds { get; init; } = [];
}
public sealed class ReportCompletedCubeRound
{
public int BlockNumber { get; init; }
public int? Number { get; init; }
public long? White { get; init; }
public long? Black { get; init; }
}
public sealed class ReportCompletedMatchResponse
{
public bool Recorded { get; init; }
public bool IsDuplicate { get; init; }
public bool IsRanked { get; init; }
public int? WhiteEloAfter { get; init; }
public int? BlackEloAfter { get; init; }
}
public sealed class UserStatsResponse
{
public string Subject { get; init; } = string.Empty;
public int CurrentElo { get; init; }
public int RankedGames { get; init; }
public int CasualGames { get; init; }
public int Wins { get; init; }
public int Losses { get; init; }
public int StoppedGames { get; init; }
public int WhiteWins { get; init; }
public int BlackWins { get; init; }
public int WhiteLosses { get; init; }
public int BlackLosses { get; init; }
public int TotalMoves { get; init; }
public int TotalCubeRounds { get; init; }
public long? BestCubeTimeMs { get; init; }
public long? AverageCubeTimeMs { get; init; }
public DateTime? LastMatchUtc { get; init; }
public UserRecentMatchResponse[] RecentMatches { get; init; } = [];
}
public sealed class UserRecentMatchResponse
{
public string MatchId { get; init; } = string.Empty;
public DateTime CompletedUtc { get; init; }
public string Result { get; init; } = string.Empty;
public string Mode { get; init; } = string.Empty;
public string Preset { get; init; } = string.Empty;
public string? MatchLabel { get; init; }
public string PlayerColor { get; init; } = string.Empty;
public string PlayerName { get; init; } = string.Empty;
public string OpponentName { get; init; } = string.Empty;
public string? OpponentSubject { get; init; }
public bool IsRanked { get; init; }
public bool IsWin { get; init; }
public bool IsLoss { get; init; }
public int PlayerMoves { get; init; }
public int OpponentMoves { get; init; }
public int CubeRounds { get; init; }
public long? PlayerBestCubeTimeMs { get; init; }
public long? PlayerAverageCubeTimeMs { get; init; }
public int? EloBefore { get; init; }
public int? EloAfter { get; init; }
public int? EloDelta { get; init; }
}
public sealed class PlayerStatsValidationException(string message) : Exception(message);

View File

@@ -0,0 +1,63 @@
using ChessCubing.Server.Auth;
using System.Security.Claims;
namespace ChessCubing.Server.Users;
public sealed record AuthenticatedSiteUser(
string Subject,
string Username,
string? Email,
string DisplayName);
public static class AuthenticatedSiteUserFactory
{
public static AuthenticatedSiteUser? FromKeycloakUserInfo(KeycloakUserInfo userInfo)
{
if (string.IsNullOrWhiteSpace(userInfo.Subject))
{
return null;
}
var username = string.IsNullOrWhiteSpace(userInfo.PreferredUsername)
? userInfo.Subject
: userInfo.PreferredUsername;
var displayName = string.IsNullOrWhiteSpace(userInfo.Name)
? username
: userInfo.Name;
return new AuthenticatedSiteUser(
userInfo.Subject.Trim(),
username.Trim(),
string.IsNullOrWhiteSpace(userInfo.Email) ? null : userInfo.Email.Trim(),
displayName.Trim());
}
public static AuthenticatedSiteUser? FromClaimsPrincipal(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
{
return null;
}
var subject = user.FindFirst("sub")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(subject))
{
return null;
}
var username = user.FindFirst("preferred_username")?.Value
?? user.Identity?.Name
?? subject;
var email = user.FindFirst("email")?.Value;
var displayName = user.FindFirst("name")?.Value
?? username;
return new AuthenticatedSiteUser(
subject.Trim(),
username.Trim(),
string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
displayName.Trim());
}
}

View File

@@ -0,0 +1,407 @@
using ChessCubing.Server.Data;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ChessCubing.Server.Users;
public sealed class MySqlUserProfileStore(
IOptions<SiteDataOptions> options,
ILogger<MySqlUserProfileStore> logger)
{
private const string CreateTableSql = """
CREATE TABLE IF NOT EXISTS site_users (
id BIGINT NOT NULL AUTO_INCREMENT,
subject VARCHAR(190) NOT NULL,
username VARCHAR(120) NOT NULL,
email VARCHAR(255) NULL,
display_name VARCHAR(120) NOT NULL,
club VARCHAR(120) NULL,
city VARCHAR(120) NULL,
preferred_format VARCHAR(40) NULL,
favorite_cube VARCHAR(120) NULL,
bio TEXT NULL,
created_utc DATETIME(6) NOT NULL,
updated_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_site_users_subject (subject)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string UpsertIdentitySql = """
INSERT INTO site_users (
subject,
username,
email,
display_name,
created_utc,
updated_utc
)
VALUES (
@subject,
@username,
@email,
@displayName,
@createdUtc,
@updatedUtc
)
ON DUPLICATE KEY UPDATE
username = VALUES(username),
email = VALUES(email);
""";
private const string UpsertProfileSql = """
INSERT INTO site_users (
subject,
username,
email,
display_name,
club,
city,
preferred_format,
favorite_cube,
bio,
created_utc,
updated_utc
)
VALUES (
@subject,
@username,
@email,
@displayName,
@club,
@city,
@preferredFormat,
@favoriteCube,
@bio,
@createdUtc,
@updatedUtc
)
ON DUPLICATE KEY UPDATE
username = VALUES(username),
email = VALUES(email),
display_name = VALUES(display_name),
club = VALUES(club),
city = VALUES(city),
preferred_format = VALUES(preferred_format),
favorite_cube = VALUES(favorite_cube),
bio = VALUES(bio),
updated_utc = VALUES(updated_utc);
""";
private const string SelectProfileSql = """
SELECT
subject,
username,
email,
display_name,
club,
city,
preferred_format,
favorite_cube,
bio,
created_utc,
updated_utc
FROM site_users
WHERE subject = @subject
LIMIT 1;
""";
private const string SelectAllProfilesSql = """
SELECT
subject,
username,
email,
display_name,
club,
city,
preferred_format,
favorite_cube,
bio,
created_utc,
updated_utc
FROM site_users
ORDER BY updated_utc DESC, created_utc DESC, username ASC;
""";
private const string DeleteProfileSql = """
DELETE FROM site_users
WHERE subject = @subject;
""";
private readonly SiteDataOptions _options = options.Value;
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
{
try
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = CreateTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
return;
}
catch (Exception exception) when (attempt < _options.InitializationRetries)
{
_logger.LogWarning(
exception,
"Initialisation MySQL impossible pour le profil utilisateur (tentative {Attempt}/{MaxAttempts}).",
attempt,
_options.InitializationRetries);
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
}
}
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
await finalConnection.OpenAsync(cancellationToken);
await using var finalCommand = finalConnection.CreateCommand();
finalCommand.CommandText = CreateTableSql;
await finalCommand.ExecuteNonQueryAsync(cancellationToken);
}
public async Task<UserProfileResponse> GetOrCreateAsync(
AuthenticatedSiteUser user,
CancellationToken cancellationToken)
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
var nowUtc = DateTime.UtcNow;
await using (var command = connection.CreateCommand())
{
command.CommandText = UpsertIdentitySql;
command.Parameters.AddWithValue("@subject", user.Subject);
command.Parameters.AddWithValue("@username", user.Username);
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
command.Parameters.AddWithValue("@displayName", user.DisplayName);
command.Parameters.AddWithValue("@createdUtc", nowUtc);
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
}
public async Task<UserProfileResponse> UpdateAsync(
AuthenticatedSiteUser user,
UpdateUserProfileRequest request,
CancellationToken cancellationToken)
{
var input = NormalizeInput(user, request);
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
var nowUtc = DateTime.UtcNow;
await using (var command = connection.CreateCommand())
{
command.CommandText = UpsertProfileSql;
command.Parameters.AddWithValue("@subject", user.Subject);
command.Parameters.AddWithValue("@username", user.Username);
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
command.Parameters.AddWithValue("@displayName", input.DisplayName);
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
command.Parameters.AddWithValue("@createdUtc", nowUtc);
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
}
public void ValidateAdminUpdate(string fallbackDisplayName, UpdateUserProfileRequest request)
=> _ = NormalizeInput(fallbackDisplayName, request);
public async Task<IReadOnlyList<UserProfileResponse>> ListAsync(CancellationToken cancellationToken)
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectAllProfilesSql;
var profiles = new List<UserProfileResponse>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
profiles.Add(MapProfile(reader));
}
return profiles;
}
public async Task<UserProfileResponse?> FindBySubjectAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectProfileSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken)
? MapProfile(reader)
: null;
}
public async Task<UserProfileResponse> AdminUpsertAsync(
string subject,
string username,
string? email,
string fallbackDisplayName,
UpdateUserProfileRequest request,
CancellationToken cancellationToken)
{
var input = NormalizeInput(fallbackDisplayName, request);
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
var nowUtc = DateTime.UtcNow;
await using (var command = connection.CreateCommand())
{
command.CommandText = UpsertProfileSql;
command.Parameters.AddWithValue("@subject", subject);
command.Parameters.AddWithValue("@username", username);
command.Parameters.AddWithValue("@email", (object?)email ?? DBNull.Value);
command.Parameters.AddWithValue("@displayName", input.DisplayName);
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
command.Parameters.AddWithValue("@createdUtc", nowUtc);
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
return await ReadProfileAsync(connection, subject, cancellationToken);
}
public async Task DeleteAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = DeleteProfileSql;
command.Parameters.AddWithValue("@subject", subject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
=> NormalizeInput(user.DisplayName, request);
private static UserProfileInput NormalizeInput(string fallbackDisplayName, UpdateUserProfileRequest request)
{
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? fallbackDisplayName;
var club = NormalizeOptionalValue(request.Club, "club", 120);
var city = NormalizeOptionalValue(request.City, "ville", 120);
var favoriteCube = NormalizeOptionalValue(request.FavoriteCube, "cube favori", 120);
var bio = NormalizeOptionalValue(request.Bio, "bio", 1200);
var preferredFormat = NormalizePreferredFormat(request.PreferredFormat);
return new UserProfileInput(displayName, club, city, preferredFormat, favoriteCube, bio);
}
private static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
{
var trimmed = value?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
if (trimmed.Length > maxLength)
{
throw new UserProfileValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
}
return trimmed;
}
private static string? NormalizePreferredFormat(string? value)
{
var normalized = NormalizeOptionalValue(value, "format prefere", 40);
if (normalized is null)
{
return null;
}
return normalized switch
{
"Twice" => "Twice",
"Time" => "Time",
"Les deux" => "Les deux",
_ => throw new UserProfileValidationException("Le format prefere doit etre Twice, Time ou Les deux."),
};
}
private static async Task<UserProfileResponse> ReadProfileAsync(
MySqlConnection connection,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = SelectProfileSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
throw new InvalidOperationException("Le profil utilisateur n'a pas pu etre charge.");
}
return MapProfile(reader);
}
private static UserProfileResponse MapProfile(MySqlDataReader reader)
{
var subjectOrdinal = reader.GetOrdinal("subject");
var usernameOrdinal = reader.GetOrdinal("username");
var emailOrdinal = reader.GetOrdinal("email");
var displayNameOrdinal = reader.GetOrdinal("display_name");
var clubOrdinal = reader.GetOrdinal("club");
var cityOrdinal = reader.GetOrdinal("city");
var preferredFormatOrdinal = reader.GetOrdinal("preferred_format");
var favoriteCubeOrdinal = reader.GetOrdinal("favorite_cube");
var bioOrdinal = reader.GetOrdinal("bio");
var createdUtcOrdinal = reader.GetOrdinal("created_utc");
var updatedUtcOrdinal = reader.GetOrdinal("updated_utc");
return new UserProfileResponse
{
Subject = reader.GetString(subjectOrdinal),
Username = reader.GetString(usernameOrdinal),
Email = reader.IsDBNull(emailOrdinal) ? null : reader.GetString(emailOrdinal),
DisplayName = reader.GetString(displayNameOrdinal),
Club = reader.IsDBNull(clubOrdinal) ? null : reader.GetString(clubOrdinal),
City = reader.IsDBNull(cityOrdinal) ? null : reader.GetString(cityOrdinal),
PreferredFormat = reader.IsDBNull(preferredFormatOrdinal) ? null : reader.GetString(preferredFormatOrdinal),
FavoriteCube = reader.IsDBNull(favoriteCubeOrdinal) ? null : reader.GetString(favoriteCubeOrdinal),
Bio = reader.IsDBNull(bioOrdinal) ? null : reader.GetString(bioOrdinal),
CreatedUtc = DateTime.SpecifyKind(reader.GetDateTime(createdUtcOrdinal), DateTimeKind.Utc),
UpdatedUtc = DateTime.SpecifyKind(reader.GetDateTime(updatedUtcOrdinal), DateTimeKind.Utc),
};
}
private sealed record UserProfileInput(
string DisplayName,
string? Club,
string? City,
string? PreferredFormat,
string? FavoriteCube,
string? Bio);
}

View File

@@ -0,0 +1,43 @@
namespace ChessCubing.Server.Users;
public sealed class UserProfileResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string? Email { get; init; }
public string DisplayName { get; init; } = string.Empty;
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public string? FavoriteCube { get; init; }
public string? Bio { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed class UpdateUserProfileRequest
{
public string? DisplayName { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public string? FavoriteCube { get; init; }
public string? Bio { get; init; }
}
public sealed class UserProfileValidationException(string message) : Exception(message);

View File

@@ -1,4 +1,16 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ChessCubing.App/ChessCubing.App.csproj ChessCubing.App/
RUN dotnet restore ChessCubing.App/ChessCubing.App.csproj
COPY ChessCubing.App/ ChessCubing.App/
COPY ethan/ ethan/
COPY brice/ brice/
COPY favicon.png logo.png transparent.png styles.css site.webmanifest ChessCubing_Time_Reglement_Officiel_V1-1.pdf ChessCubing_Twice_Reglement_Officiel_V2-1.pdf ./
RUN dotnet publish ChessCubing.App/ChessCubing.App.csproj -c Release -o /app/publish
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY . /usr/share/nginx/html
COPY --from=build /app/publish/wwwroot /usr/share/nginx/html

17
Dockerfile.auth Normal file
View File

@@ -0,0 +1,17 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ChessCubing.Server/ChessCubing.Server.csproj ChessCubing.Server/
RUN dotnet restore ChessCubing.Server/ChessCubing.Server.csproj
COPY ChessCubing.Server/ ChessCubing.Server/
RUN dotnet publish ChessCubing.Server/ChessCubing.Server.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
COPY --from=build /app/publish ./
ENTRYPOINT ["dotnet", "ChessCubing.Server.dll"]

188
README.md
View File

@@ -1,25 +1,63 @@
# ChessCubing Arena
Application web mobile-first pour téléphone et tablette, pensée comme application officielle de suivi de match pour `ChessCubing Twice` et `ChessCubing Time`.
Application web mobile-first pour telephone et tablette, maintenant migree vers **Blazor WebAssembly en C# / .NET 10**.
## Ce que fait cette première version
## Ce que fait l'application
- configure une rencontre `Twice` ou `Time`
- sépare l'application en pages dédiées : configuration, phase chrono, phase cube
- permet de définir librement le temps de partie et le temps par coup
- separe l'experience en pages dediees : configuration, phase chrono, phase cube
- permet de definir librement le temps de partie et le temps par coup
- suit les quotas `FAST`, `FREEZE` et `MASTERS`
- orchestre la phase cube avec désignation du cube, capture des temps et préparation de la partie suivante
- orchestre la phase cube avec designation du cube, capture des temps et preparation de la partie suivante
- applique la logique du double coup V2 en `Twice`
- applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte
- conserve un historique local dans le navigateur
- propose une page chrono pensée pour le téléphone avec deux grandes zones tactiles, une par joueur
- ouvre automatiquement la page cube dès que la phase chess de la partie est terminée
- conserve l'etat du match dans le navigateur
- propose une page chrono pensee pour le telephone avec deux grandes zones tactiles
- ouvre automatiquement la page cube des que la phase chess est terminee
## Hypothèse de produit
## Architecture
Cette version est volontairement construite comme une **application d'arbitrage et de direction de match** autour d'un vrai échiquier physique, et non comme un moteur d'échecs complet. C'est le choix le plus fidèle aux règlements fournis et le plus réaliste pour une utilisation immédiate en club, en démonstration ou en tournoi.
Le coeur de l'application se trouve dans `ChessCubing.App/`.
## Démarrage avec Docker
- `ChessCubing.App/Pages/` : pages Razor du site et de l'application
- `ChessCubing.App/Services/MatchEngine.cs` : logique metier des matchs
- `ChessCubing.App/Services/MatchStore.cs` : persistance navigateur
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : session locale cote client
- `ChessCubing.Server/` : backend d'authentification Keycloak et API utilisateur reliee a MySQL
- `ChessCubing.App/wwwroot/` : assets statiques, manifeste, PDFs, appli Ethan
- `keycloak/realm/chesscubing-realm.json` : realm importable avec client Keycloak et roles
- `keycloak/scripts/init-config.sh` : synchronisation automatique du client Keycloak au demarrage
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : front Blazor, API d'auth, Keycloak/Postgres et MySQL pour les donnees du site
Le projet continue a exposer les routes historiques `index.html`, `application.html`, `chrono.html`, `cube.html` et `reglement.html`.
## Authentification Keycloak
L'application embarque maintenant une authentification integree basee sur Keycloak, sans redirection utilisateur vers une page externe.
- toutes les pages du site restent accessibles sans connexion
- un menu general en haut des pages site regroupe la navigation et les actions `Se connecter` / `Creer un compte` dans une modal integree
- le formulaire de connexion et le formulaire de creation de compte sont rendus directement dans l'application
- un backend local `ChessCubing.Server` appelle Keycloak cote serveur pour la connexion, l'inscription et la recuperation du profil
- une session cookie locale est ensuite exposee au front via `/api/auth/session`
- les roles Keycloak du realm restent exposes dans l'application
- l'etat du match est isole par utilisateur dans le navigateur grace a une cle de stockage derivee du compte connecte
- une page `utilisateur` permet maintenant d'editer un profil du site persiste en base MySQL via `/api/users/me`
- une page `administration` reservee au role `admin` propose maintenant une table utilisateurs avec actions d'ajout, modification et suppression via `/api/admin/users`
Le realm importe par defaut :
- realm : `chesscubing`
- client Keycloak : `chesscubing-web`
- roles de realm : `admin`, `organizer`, `player`
- inscription utilisateur : activee
- direct access grant : active
La gestion des utilisateurs peut maintenant demarrer depuis la page d'administration du site pour les usages courants. La console d'administration Keycloak reste utile pour les reglages avances, notamment les roles.
## Demarrage local
### Avec Docker
```bash
docker compose down
@@ -28,16 +66,50 @@ docker compose up -d --build
L'application est ensuite disponible sur `http://localhost:8080`.
## Déploiement dans un LXC Proxmox
La console Keycloak est servie via le meme nginx sur `http://localhost:8080/auth/admin/`.
L'API d'authentification integree est servie derriere le meme point d'entree via `/api/auth/*`.
L'API utilisateur et sa persistance MySQL sont egalement servies via `/api/users/*`.
Deux scripts Bash permettent de créer un conteneur LXC Debian sur Proxmox puis de le mettre à jour depuis Git.
Identifiants d'administration par defaut pour le premier demarrage local :
Prérequis sur la machine qui lance les scripts :
- utilisateur : `admin`
- mot de passe : `admin`
Ces valeurs peuvent etre surchargees via les variables d'environnement de `.env.example`.
La base MySQL du site utilise les variables `SITE_DB_*` du meme fichier.
Pour un deploiement hors localhost, `PUBLIC_BASE_URL` doit pointer vers l'URL publique du site et `WEB_PORT` vers le port HTTP expose.
Au demarrage, le service `keycloak-init` resynchronise automatiquement le realm courant pour garder l'inscription active et autoriser le flux de connexion integre, meme si la base Keycloak existe deja.
Si vous voulez reimporter completement le realm fourni dans `keycloak/realm/`, il faut repartir d'une base vide, par exemple avec `docker compose down -v` avant le redemarrage.
### Avec .NET 10
```bash
dotnet restore ChessCubing.App/ChessCubing.App.csproj
dotnet run --project ChessCubing.App/ChessCubing.App.csproj
```
Le mode Docker reste la voie recommandee pour cette integration, car nginx y reverse-proxy egalement Keycloak sous `/auth/` et l'API d'auth sous `/api/`.
## Deploiement dans un LXC Proxmox
Deux scripts Bash permettent de creer un conteneur LXC Debian sur Proxmox puis de le mettre a jour depuis Git.
Prerrequis sur la machine qui lance les scripts :
- en mode distant : `ssh` et `sshpass`
- en mode local sur l'hôte Proxmox : aucun paquet supplémentaire n'est installé sur Proxmox
- en mode local sur l'hote Proxmox : aucun paquet supplementaire n'est installe sur Proxmox
Le déploiement dans le LXC n'utilise pas Docker. Le script installe `nginx`, `git` et `rsync` dans le conteneur, clone le dépôt principal, synchronise aussi le projet d'Ethan, puis publie uniquement les fichiers web.
Le deploiement dans le LXC Proxmox utilise maintenant Docker dans le conteneur pour lancer la meme stack qu'en local : `web`, `auth`, `keycloak`, `postgres` et `mysql`.
Le script prepare une URL publique pour Keycloak via `PUBLIC_BASE_URL`, installe Docker dans le LXC, puis lance la stack dans le conteneur.
Les mises a jour conservent maintenant `mysql`, `postgres` et `keycloak` en place. Le script ne fait plus de `docker compose down` ni de purge complete des images a chaque update, ce qui evite de retelecharger ou recreer inutilement l'infrastructure.
Si le site est expose derriere un reverse proxy, `PUBLIC_BASE_URL` doit etre l'URL publique finale servie par ce proxy.
Pour un usage confortable, il est recommande de prevoir un LXC avec au moins 2 vCPU, 4 Go de RAM et 16 Go de disque.
### Installer un nouveau LXC
@@ -45,7 +117,8 @@ Le déploiement dans le LXC n'utilise pas Docker. Le script installe `nginx`, `g
./scripts/install-proxmox-lxc.sh \
--proxmox-host 10.0.0.2 \
--proxmox-user root@pam \
--proxmox-password 'secret'
--proxmox-password 'secret' \
--public-base-url https://jeu.exemple.fr
```
Version "curl | bash" :
@@ -54,45 +127,16 @@ Version "curl | bash" :
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/install-chesscubing-proxmox.sh)"
```
Cette version pose les questions nécessaires si les variables d'environnement ne sont pas déjà définies.
Si elle est lancée directement sur l'hôte Proxmox, elle passe automatiquement en mode local :
- elle ne demande ni serveur, ni login, ni mot de passe SSH
- elle n'installe rien sur l'hôte Proxmox
- elle crée uniquement le LXC puis installe les dépendances dans ce LXC
Valeurs par défaut utiles :
- LXC nommé `chesscubing-web`
- IP du LXC en `dhcp`
- branche Git `main`
- dépôt `https://git.jeannerot.fr/christophe/chesscubing.git`
- dépôt Ethan `https://git.jeannerot.fr/Mineloulou/Chesscubing.git`
Options utiles si besoin :
- `--ctid 120`
- `--lxc-ip 192.168.1.50/24 --gateway 192.168.1.1`
- `--template-storage local`
- `--rootfs-storage local-lvm`
- `--branch main`
- `--ethan-branch main`
À la fin, le script affiche :
- le `CTID`
- le mot de passe `root` du LXC
- l'URL probable du site
### Mettre à jour depuis Git
### Mettre a jour depuis Git
```bash
./scripts/update-proxmox-lxc.sh \
--proxmox-host 10.0.0.2 \
--proxmox-user root@pam \
--proxmox-password 'secret' \
--ctid 120
--ctid 120 \
--public-base-url https://jeu.exemple.fr \
--disk-gb 16
```
Version "curl | bash" :
@@ -101,30 +145,22 @@ Version "curl | bash" :
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/update-chesscubing-proxmox.sh)"
```
Sur l'hôte Proxmox, cette commande met à jour le LXC local sans passer par SSH.
Par défaut, elle cible le conteneur `chesscubing-web` sans demander le `CTID`.
## Fichiers cles
On peut aussi cibler le conteneur par nom si on n'a pas le `CTID` :
```bash
./scripts/update-proxmox-lxc.sh \
--proxmox-host 10.0.0.2 \
--proxmox-user root@pam \
--proxmox-password 'secret' \
--hostname chesscubing-web
```
Le script de mise à jour exécute un `git pull --ff-only` pour le dépôt principal et le dépôt d'Ethan dans le conteneur, puis republie les fichiers statiques via `nginx`, y compris la route `/ethan/`.
## Fichiers clés
- `index.html` : page d'accueil du site
- `application.html` : page de configuration et reprise de match
- `chrono.html` : page dédiée à la phase chrono
- `cube.html` : page dédiée à la phase cube
- `reglement.html` : page éditoriale qui présente le règlement officiel
- `styles.css` : design mobile/tablette
- `app.js` : logique de match et arbitrage
- `docker-compose.yml` + `Dockerfile` : exécution locale
- `scripts/install-proxmox-lxc.sh` : création et déploiement d'un LXC Proxmox
- `scripts/update-proxmox-lxc.sh` : mise à jour d'un LXC existant depuis Git
- `ChessCubing.App/Pages/Home.razor` : page d'accueil du site
- `ChessCubing.App/Pages/UserPage.razor` : page utilisateur connectee a MySQL
- `ChessCubing.App/Pages/AdminPage.razor` : premiere page d'administration pour gerer les utilisateurs
- `ChessCubing.App/Pages/ApplicationPage.razor` : configuration et reprise de match
- `ChessCubing.App/Pages/ChronoPage.razor` : phase chrono
- `ChessCubing.App/Pages/CubePage.razor` : phase cube
- `ChessCubing.App/Pages/RulesPage.razor` : synthese du reglement
- `ChessCubing.App/Services/MatchEngine.cs` : regles de jeu et transitions
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : etat de session cote client
- `ChessCubing.Server/Program.cs` : endpoints `/api/auth/*` et `/api/users/*`
- `ChessCubing.Server/Program.cs` : endpoints `/api/auth/*`, `/api/users/*` et `/api/admin/users/*`
- `ChessCubing.Server/Users/MySqlUserProfileStore.cs` : creation de table et persistance du profil utilisateur
- `keycloak/realm/chesscubing-realm.json` : realm, roles et client Keycloak importes
- `keycloak/scripts/init-config.sh` : mise en conformite du client Keycloak au demarrage
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : execution locale
- `scripts/install-proxmox-lxc.sh` : creation et deploiement d'un LXC Proxmox
- `scripts/update-proxmox-lxc.sh` : mise a jour d'un LXC existant depuis Git

31
app.js
View File

@@ -32,7 +32,7 @@ const PRESETS = {
const MODES = {
twice: {
label: "ChessCubing Twice",
subtitle: "Le gagnant du cube ouvre la partie suivante.",
subtitle: "Le gagnant du cube ouvre le Block suivant.",
},
time: {
label: "ChessCubing Time",
@@ -298,9 +298,9 @@ function initSetupPage() {
const timeImpact =
mode === "time"
? `Chronos cumules de ${formatClock(timeInitialMs)} par joueur, ajustes apres chaque phase cube avec plafond de 120 s pris en compte. Aucun temps par coup en mode Time.`
: "Le gagnant du cube commence la partie suivante, avec double coup V2 possible.";
: "Le gagnant du cube commence le Block suivant, avec double coup V2 possible.";
const timingText = moveLimitActive
? `Temps configures : partie ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}.`
? `Temps configures : Block ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}.`
: `Temps configures : Block ${formatClock(blockDurationMs)}, temps de chaque joueur ${formatClock(timeInitialMs)}.`;
const quotaText = moveLimitActive
? `Quota actif : ${quota} coups par joueur.`
@@ -325,8 +325,7 @@ function initSetupPage() {
document.body.classList.toggle("time-setup-mode", !moveLimitActive);
if (blockDurationLabel instanceof HTMLElement) {
blockDurationLabel.textContent =
blockLabel === "Block" ? "Temps du Block (minutes)" : "Temps partie (minutes)";
blockDurationLabel.textContent = "Temps du Block (minutes)";
}
syncCompetitionFields();
@@ -742,7 +741,7 @@ function initChronoPage() {
document.body.classList.toggle("time-mode", timeMode);
refs.title.textContent = match.config.matchLabel;
refs.subtitle.textContent = `${blockHeading} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`;
refs.blockTimerLabel.textContent = timeMode ? "Temps Block" : "Temps partie";
refs.blockTimerLabel.textContent = "Temps Block";
refs.blockTimer.textContent = formatClock(match.blockRemainingMs);
refs.moveTimer.textContent = usesMoveLimit(match) ? formatClock(match.moveRemainingMs) : "--:--";
refs.moveTimerCard.hidden = timeMode;
@@ -775,8 +774,8 @@ function initChronoPage() {
refs.spineLabel.textContent = timeMode ? "Etat du Block" : "Pret";
refs.spineHeadline.textContent = blockHeading;
refs.spineText.textContent =
"Demarrez la partie, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.";
refs.primaryButton.textContent = timeMode ? "Demarrer le Block" : "Demarrer la partie";
"Demarrez le Block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.";
refs.primaryButton.textContent = "Demarrer le Block";
refs.arbiterStatus.textContent = `${blockHeading} pret. ${playerName(match, match.currentTurn)} commencera.`;
}
@@ -1256,10 +1255,10 @@ function initCubePage() {
key: `twice:${match.blockNumber}:${match.cube.round}:${white}:${black}`,
title: isTie ? "Egalite parfaite" : "Résumé du cube",
winner: winner ? playerName(match, winner) : "Egalite parfaite",
outcome: isTie ? "Rejouer la phase cube" : `${playerName(match, winner)} ouvrira la partie suivante`,
outcome: isTie ? "Rejouer la phase cube" : `${playerName(match, winner)} ouvrira le Block suivant`,
summary: isTie
? "Le règlement Twice impose de rejouer immédiatement la phase cube."
: "Validez ce résultat pour préparer la partie suivante.",
: "Validez ce résultat pour preparer le Block suivant.",
actionLabel: isTie ? "Rejouer la phase cube" : "Appliquer et ouvrir la page chrono",
whiteName,
blackName,
@@ -1353,7 +1352,7 @@ function initCubePage() {
refs.centerValue.textContent = "Phase cube complete";
refs.spineLabel.textContent = "Suite";
refs.spineHeadline.textContent = "Ouvrir la page chrono";
refs.spineText.textContent = "Appliquer le resultat du cube pour preparer la partie suivante.";
refs.spineText.textContent = "Appliquer le resultat du cube pour preparer le Block suivant.";
}
refs.primaryButton.textContent = "Voir le résumé du cube";
refs.helpStatus.textContent = refs.spineText.textContent;
@@ -1467,7 +1466,7 @@ function createMatch(config) {
logEvent(
newMatch,
usesMoveLimit(config.mode)
? `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, partie ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`
? `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, Block ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`
: `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, Block ${formatClock(config.blockDurationMs)} et chrono initial ${formatClock(getTimeInitialMs(config))} par joueur, sans temps par coup.`,
);
logEvent(newMatch, `Les Blancs commencent ${formatBlockHeading(config, 1)}.`);
@@ -1782,7 +1781,7 @@ function applyCubeOutcome(storedMatch) {
function prepareNextTwiceBlock(storedMatch, winner) {
const hadDouble = storedMatch.lastMover !== winner && storedMatch.lastMover !== null;
logEvent(storedMatch, `${playerName(storedMatch, winner)} gagne la phase cube et ouvrira la partie suivante.`);
logEvent(storedMatch, `${playerName(storedMatch, winner)} gagne la phase cube et ouvrira le Block suivant.`);
storedMatch.blockNumber += 1;
storedMatch.phase = "block";
@@ -2264,15 +2263,15 @@ function getTimeInitialMs(matchOrConfig) {
}
function getBlockLabel(matchOrConfig) {
return isTimeMode(matchOrConfig) ? "Block" : "Partie";
return "Block";
}
function getBlockPhrase(matchOrConfig) {
return isTimeMode(matchOrConfig) ? "Le Block" : "La partie";
return "Le Block";
}
function getBlockGenitivePhrase(matchOrConfig) {
return isTimeMode(matchOrConfig) ? "du Block" : "de la partie";
return "du Block";
}
function formatBlockHeading(matchOrConfig, blockNumber) {

View File

@@ -51,10 +51,8 @@
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
};
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = window.__CHESSCUBING_ASSET_URL__("styles.css");
document.head.append(stylesheet);
const stylesheetHref = window.__CHESSCUBING_ASSET_URL__("styles.css");
document.write(`<link rel="stylesheet" href="${stylesheetHref}" />`);
})();
</script>
</head>
@@ -129,7 +127,7 @@
<input type="radio" name="mode" value="twice" checked />
<strong>ChessCubing Twice</strong>
<span>
Le gagnant du cube ouvre la partie suivante et peut obtenir un
Le gagnant du cube ouvre le Block suivant et peut obtenir un
double coup V2.
</span>
</label>
@@ -169,7 +167,7 @@
<legend>Temps personnalisés</legend>
<div class="timing-grid">
<label class="field">
<span id="blockDurationLabel">Temps partie (minutes)</span>
<span id="blockDurationLabel">Temps du Block (minutes)</span>
<input
name="blockMinutes"
type="number"

25
brice/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
.DS_Store
# Generated by package manager
node_modules/
# Generated by Cordova
/plugins/
/platforms/

13
brice/config.xml Normal file
View File

@@ -0,0 +1,13 @@
<?xml version='1.0' encoding='utf-8'?>
<widget id="org.apache.cordova.hellocordova" version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<name>Hello Cordova</name>
<description>
A sample Apache Cordova application that responds to the deviceready event.
</description>
<author email="dev@cordova.apache.org" href="https://cordova.apache.org">
Apache Cordova Team
</author>
<content src="index.html" />
<allow-intent href="http://*/*" />
<allow-intent href="https://*/*" />
</widget>

897
brice/package-lock.json generated Normal file
View File

@@ -0,0 +1,897 @@
{
"name": "org.apache.cordova.hellocordova",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "org.apache.cordova.hellocordova",
"version": "1.0.0",
"license": "Apache-2.0",
"devDependencies": {
"cordova-android": "^14.0.1"
}
},
"node_modules/@netflix/nerror": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@netflix/nerror/-/nerror-1.1.3.tgz",
"integrity": "sha512-b+MGNyP9/LXkapreJzNUzcvuzZslj/RGgdVVJ16P2wSlYatfLycPObImqVJSmNAdyeShvNeM/pl3sVZsObFueg==",
"dev": true,
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"extsprintf": "^1.4.0",
"lodash": "^4.17.15"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/abbrev": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
"integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/android-versions": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/android-versions/-/android-versions-2.1.0.tgz",
"integrity": "sha512-oCBvVs2uaL8ohQtesGs78/X7QvFDLbKgTosBRiOIBCss1a/yiakQm/ADuoG2k/AUaI0FfrsFeMl/a+GtEtjEeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.2"
}
},
"node_modules/ansi": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz",
"integrity": "sha512-iFY7JCgHbepc0b82yLaw4IMortylNb6wG4kL+4R0C3iv6i+RHGHux/yUX5BTiRvSX/shMnngjR1YyNMnXEFh5A==",
"dev": true,
"license": "MIT"
},
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"dev": true,
"license": "Unlicense",
"engines": {
"node": ">=0.6"
}
},
"node_modules/bplist-parser": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz",
"integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"big-integer": "1.6.x"
},
"engines": {
"node": ">= 5.10.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cordova-android": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/cordova-android/-/cordova-android-14.0.1.tgz",
"integrity": "sha512-HMBMdGu/JlSQtmBuDEpKWf/pE75SpF3FksxZ+mqYuL3qSIN8lN/QsNurwYaPAP7zWXN2DNpvwlpOJItS5VhdLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"android-versions": "^2.1.0",
"cordova-common": "^5.0.1",
"dedent": "^1.5.3",
"execa": "^5.1.1",
"fast-glob": "^3.3.3",
"is-path-inside": "^3.0.3",
"nopt": "^8.1.0",
"properties-parser": "^0.6.0",
"semver": "^7.7.1",
"string-argv": "^0.3.1",
"untildify": "^4.0.0",
"which": "^5.0.0"
},
"engines": {
"node": ">=20.5.0"
}
},
"node_modules/cordova-common": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/cordova-common/-/cordova-common-5.0.1.tgz",
"integrity": "sha512-OA2NQ6wvhNz4GytPYwTdlA9xfG7Yf7ufkj4u97m3rUfoL/AECwwj0GVT2CYpk/0Fk6HyuHA3QYCxfDPYsKzI1A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@netflix/nerror": "^1.1.3",
"ansi": "^0.3.1",
"bplist-parser": "^0.3.2",
"cross-spawn": "^7.0.6",
"elementtree": "^0.1.7",
"endent": "^2.1.0",
"fast-glob": "^3.3.3",
"lodash.zip": "^4.2.0",
"plist": "^3.1.0",
"q": "^1.5.1",
"read-chunk": "^3.2.0",
"strip-bom": "^4.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cross-spawn/node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/cross-spawn/node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/dedent": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
"integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
},
"peerDependenciesMeta": {
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/elementtree": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz",
"integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"sax": "1.1.4"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/endent": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/endent/-/endent-2.1.0.tgz",
"integrity": "sha512-r8VyPX7XL8U01Xgnb1CjZ3XV+z90cXIJ9JPE/R9SEC9vpw2P6CfsRPJmp20DppC5N7ZAMCmjYkJIa744Iyg96w==",
"dev": true,
"license": "MIT",
"dependencies": {
"dedent": "^0.7.0",
"fast-json-parse": "^1.0.3",
"objectorarray": "^1.0.5"
}
},
"node_modules/endent/node_modules/dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
"dev": true,
"license": "MIT"
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/extsprintf": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
"integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==",
"dev": true,
"engines": [
"node >=0.6.0"
],
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-json-parse": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz",
"integrity": "sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==",
"dev": true,
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.zip": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz",
"integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==",
"dev": true,
"license": "MIT"
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/nopt": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
"integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==",
"dev": true,
"license": "ISC",
"dependencies": {
"abbrev": "^3.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/objectorarray": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/objectorarray/-/objectorarray-1.0.5.tgz",
"integrity": "sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg==",
"dev": true,
"license": "ISC"
},
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
"integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
"base64-js": "^1.5.1",
"xmlbuilder": "^15.1.1"
},
"engines": {
"node": ">=10.4.0"
}
},
"node_modules/properties-parser": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/properties-parser/-/properties-parser-0.6.0.tgz",
"integrity": "sha512-qvr2cSmoA0dln0MARAKwBzPkkXn7FqwX+RVVNpMdMJc7rt9mqO2cXwluxtux9fHrLhjnPFaQkS8BM0kFrTCnSw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.3.1"
}
},
"node_modules/q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
"integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
"deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.0",
"teleport": ">=0.2.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/read-chunk": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.2.0.tgz",
"integrity": "sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^4.0.1",
"with-open-file": "^0.1.6"
},
"engines": {
"node": ">=6"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/sax": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz",
"integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==",
"dev": true,
"license": "ISC"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true,
"license": "ISC"
},
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/strip-bom": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
"integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/untildify": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
"integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/with-open-file": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz",
"integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-finally": "^1.0.0",
"p-try": "^2.1.0",
"pify": "^4.0.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.0"
}
}
}
}

23
brice/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "org.apache.cordova.hellocordova",
"displayName": "Hello Cordova",
"version": "1.0.0",
"description": "A sample Apache Cordova application that responds to the deviceready event.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"ecosystem:cordova"
],
"author": "Apache Cordova Team",
"license": "Apache-2.0",
"devDependencies": {
"cordova-android": "^14.0.1"
},
"cordova": {
"platforms": [
"android"
]
}
}

1
brice/www/cordova.js vendored Normal file
View File

@@ -0,0 +1 @@
// Browser stub for Cordova builds.

View File

@@ -0,0 +1,269 @@
html,
body {
height: 100%;
}
body {
overflow: hidden;
background: #c89d70;
}
.scene-clock {
position: relative;
overflow: hidden;
min-height: 100vh;
background:
radial-gradient(circle at top center, rgba(255, 235, 200, 0.5), transparent 24%),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(112, 68, 21, 0.18)),
repeating-linear-gradient(
0deg,
#d8b186 0,
#d8b186 2px,
#c99d71 2px,
#c99d71 5px,
#bc8f65 5px,
#bc8f65 7px
);
}
.scene-clock::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(circle at 50% 39%, rgba(255, 217, 122, 0.75), transparent 8%),
radial-gradient(circle at 50% 47%, rgba(255, 221, 143, 0.42), transparent 18%);
pointer-events: none;
}
.clock-shell {
position: relative;
z-index: 1;
box-sizing: border-box;
min-height: 100vh;
padding: 4vh 5vw 6vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 5vh;
}
.clock-topbar {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.8vh;
}
.clock-board {
width: min(1100px, 100%);
display: grid;
grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr);
align-items: center;
gap: clamp(18px, 3vw, 40px);
}
.player-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 2.4vh;
}
.player-badge {
min-width: 190px;
padding: 0.45em 1.4em;
border-radius: 18px;
border: 1px solid rgba(110, 72, 34, 0.22);
background: linear-gradient(180deg, rgba(255, 246, 231, 0.96), rgba(232, 207, 171, 0.92));
color: #352112;
text-align: center;
font-family: "Cinzel", serif;
font-size: clamp(1.6rem, 2vw, 2.4rem);
letter-spacing: 0.05em;
box-shadow:
0 4px 14px rgba(80, 43, 8, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.player-badge-dark {
border-color: rgba(42, 24, 14, 0.5);
background: linear-gradient(180deg, rgba(63, 39, 22, 0.96), rgba(37, 22, 13, 0.98));
color: #f6ead7;
box-shadow:
0 4px 18px rgba(37, 16, 7, 0.28),
inset 0 1px 0 rgba(255, 235, 214, 0.14);
}
.clock-panel {
position: relative;
width: min(100%, 460px);
min-height: clamp(280px, 48vh, 420px);
border-radius: 38px;
padding: clamp(28px, 4vh, 42px) clamp(24px, 3vw, 34px);
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2.4vh;
box-shadow:
0 20px 30px rgba(74, 45, 20, 0.18),
inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.clock-panel-white {
background: linear-gradient(180deg, rgba(255, 246, 228, 0.98), rgba(241, 215, 171, 0.98));
border: 2px solid rgba(255, 211, 94, 0.75);
color: #2c1d14;
}
.clock-panel-black {
background: linear-gradient(180deg, rgba(77, 53, 37, 0.96), rgba(43, 29, 21, 0.98));
border: 2px solid rgba(46, 28, 20, 0.78);
color: #f0e1cf;
}
.panel-light {
display: none;
}
.clock-divider {
width: 100%;
height: min(48vh, 420px);
border-radius: 999px;
background: linear-gradient(180deg, rgba(183, 131, 68, 0), rgba(183, 131, 68, 0.9), rgba(183, 131, 68, 0));
box-shadow: 0 0 22px rgba(255, 211, 119, 0.7);
}
.scene-clock .button {
position: absolute;
inset: 0;
width: auto;
height: auto;
top: auto;
right: auto;
border-radius: inherit;
cursor: pointer;
z-index: 2;
background: transparent;
-webkit-tap-highlight-color: transparent;
}
.scene-clock #white_button,
.scene-clock #black_button {
inset: 0;
width: 100%;
height: 100%;
background: transparent;
}
.scene-clock .click {
transform: scale(0.985);
filter: brightness(0.96);
}
.scene-clock .TextClock {
margin: 0;
position: relative;
z-index: 1;
text-align: center;
font-family: "Cormorant Garamond", serif;
font-weight: 500;
}
.scene-clock #TimeWhite,
.scene-clock #TimeBlack,
.scene-clock #BlockTime,
.scene-clock #MoveLeftWhite,
.scene-clock #MoveLeftBlack,
.scene-clock #BlockType {
top: auto;
left: auto;
}
.clock-status {
color: #2f1d13;
font-size: clamp(2rem, 2.6vw, 3.4rem);
}
.clock-mode {
min-width: 220px;
padding: 0.28em 1.2em;
border-radius: 999px;
border: 1px solid rgba(112, 76, 41, 0.3);
background: linear-gradient(180deg, rgba(251, 242, 228, 0.98), rgba(232, 208, 178, 0.94));
color: #342012;
font-size: clamp(1.8rem, 2.2vw, 3rem);
box-shadow:
0 5px 16px rgba(111, 70, 28, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.clock-time {
font-size: clamp(4.8rem, 9vw, 8.4rem);
line-height: 0.95;
letter-spacing: 0.02em;
}
.clock-moves {
max-width: 82%;
font-size: clamp(2rem, 3.2vw, 3.6rem);
line-height: 1.02;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
}
.glow-left::before {
opacity: 1;
background: radial-gradient(circle at 24% 58%, rgba(255, 214, 115, 0.35), transparent 22%);
}
.glow-right::before {
opacity: 1;
background: radial-gradient(circle at 76% 58%, rgba(255, 196, 92, 0.22), transparent 22%);
}
.glow-left #white_button {
box-shadow:
0 0 0 2px rgba(255, 220, 113, 0.8),
0 0 38px rgba(255, 205, 95, 0.55),
0 0 70px rgba(255, 202, 93, 0.35);
}
.glow-right #black_button {
box-shadow:
0 0 0 2px rgba(255, 196, 102, 0.72),
0 0 34px rgba(255, 179, 82, 0.3),
0 0 62px rgba(255, 179, 82, 0.16);
}
@media (max-width: 900px) {
.clock-shell {
padding: 3vh 4vw 5vh;
gap: 3vh;
}
.clock-board {
grid-template-columns: 1fr;
gap: 20px;
}
.clock-divider {
width: 72%;
height: 8px;
justify-self: center;
}
.clock-panel {
min-height: 250px;
}
}

View File

@@ -0,0 +1,230 @@
.scene-cube {
position: relative;
overflow: hidden;
min-height: 100vh;
background:
radial-gradient(circle at 50% 18%, rgba(255, 236, 196, 0.28), transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(41, 28, 18, 0.16)),
repeating-linear-gradient(
0deg,
#d3ae84 0,
#d3ae84 2px,
#c39a70 2px,
#c39a70 5px,
#b78760 5px,
#b78760 7px
);
}
.cube-shell {
position: relative;
z-index: 1;
box-sizing: border-box;
min-height: 100vh;
padding: 4vh 5vw 6vh;
display: flex;
flex-direction: column;
align-items: center;
gap: 4vh;
}
.cube-topbar {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.4vh;
max-width: 720px;
text-align: center;
}
.cube-title {
position: relative;
margin: 0;
color: #2f1d13;
font-family: "Cinzel", serif;
font-size: clamp(2.1rem, 2.6vw, 3.6rem);
letter-spacing: 0.06em;
text-transform: uppercase;
}
#BlockTypeTimer {
position: relative;
margin: 0;
min-width: 220px;
padding: 0.3em 1.2em;
border-radius: 999px;
border: 1px solid rgba(105, 70, 35, 0.28);
background: linear-gradient(180deg, rgba(251, 242, 228, 0.97), rgba(230, 205, 174, 0.92));
color: #372315;
text-align: center;
box-shadow:
0 6px 18px rgba(93, 57, 25, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.cube-subtitle {
margin: 0;
color: rgba(60, 37, 24, 0.86);
font-family: "Cormorant Garamond", serif;
font-size: clamp(1.3rem, 1.7vw, 2rem);
}
.cube-board {
width: min(1120px, 100%);
display: grid;
grid-template-columns: minmax(0, 1fr) 14px minmax(0, 1fr);
align-items: stretch;
gap: clamp(18px, 3vw, 42px);
}
.cube-lane {
display: flex;
flex-direction: column;
align-items: center;
gap: 2.2vh;
}
.cube-badge {
min-width: 190px;
padding: 0.45em 1.4em;
border-radius: 18px;
border: 1px solid rgba(110, 72, 34, 0.22);
background: linear-gradient(180deg, rgba(255, 246, 231, 0.96), rgba(232, 207, 171, 0.92));
color: #352112;
text-align: center;
font-family: "Cinzel", serif;
font-size: clamp(1.6rem, 2vw, 2.4rem);
letter-spacing: 0.05em;
box-shadow:
0 4px 14px rgba(80, 43, 8, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.cube-badge-dark {
border-color: rgba(42, 24, 14, 0.5);
background: linear-gradient(180deg, rgba(63, 39, 22, 0.96), rgba(37, 22, 13, 0.98));
color: #f6ead7;
}
.cube-divider {
width: 100%;
border-radius: 999px;
background: linear-gradient(180deg, rgba(183, 131, 68, 0), rgba(183, 131, 68, 0.9), rgba(183, 131, 68, 0));
box-shadow: 0 0 22px rgba(255, 211, 119, 0.55);
}
.TimerCube {
position: relative;
width: min(100%, 470px);
min-height: clamp(320px, 52vh, 470px);
box-sizing: border-box;
padding: clamp(24px, 4vh, 38px);
border-radius: 40px;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2.6vh;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
-webkit-tap-highlight-color: transparent;
}
.cube-pad {
background: linear-gradient(180deg, rgba(248, 233, 207, 0.98), rgba(223, 190, 149, 0.98));
border: 2px solid rgba(235, 202, 120, 0.72);
color: #2a1b12;
box-shadow:
0 18px 28px rgba(88, 52, 21, 0.16),
inset 0 0 0 1px rgba(255, 255, 255, 0.24);
}
.cube-pad-dark {
background: linear-gradient(180deg, rgba(86, 58, 39, 0.98), rgba(53, 35, 24, 0.98));
border: 2px solid rgba(74, 47, 30, 0.96);
color: #f3e4d2;
box-shadow:
0 18px 30px rgba(29, 18, 11, 0.3),
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.cube-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.12) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.12) 1px, transparent 1px);
background-size: 33.333% 33.333%;
opacity: 0.15;
pointer-events: none;
}
.cube-pad-dark .cube-grid {
opacity: 0.08;
}
.TimerText {
position: relative;
margin: 0;
z-index: 1;
font-family: "Cormorant Garamond", serif;
font-size: clamp(2rem, 3vw, 3.5rem);
text-align: center;
}
#TextWhite,
#TextBlack {
color: inherit;
}
.cube-hint {
position: relative;
z-index: 1;
margin: 0;
font-family: "Cormorant Garamond", serif;
font-size: clamp(1.3rem, 1.8vw, 2rem);
letter-spacing: 0.03em;
opacity: 0.84;
}
.TimerCube.RedClick {
background: linear-gradient(180deg, rgba(237, 92, 81, 0.98), rgba(148, 26, 23, 0.98));
border-color: rgba(122, 12, 12, 0.85);
color: #fff1ec;
box-shadow:
0 0 0 2px rgba(255, 133, 116, 0.32),
0 22px 34px rgba(111, 21, 16, 0.3);
}
.TimerCube.GreenClick {
background: linear-gradient(180deg, rgba(124, 223, 87, 0.98), rgba(38, 121, 40, 0.98));
border-color: rgba(39, 96, 41, 0.88);
color: #effde9;
box-shadow:
0 0 0 2px rgba(151, 255, 134, 0.28),
0 22px 34px rgba(25, 84, 28, 0.28);
}
.TimerCube:active {
transform: scale(0.988);
}
@media (max-width: 900px) {
.cube-shell {
padding: 3vh 4vw 5vh;
}
.cube-board {
grid-template-columns: 1fr;
}
.cube-divider {
width: 72%;
min-height: 8px;
}
.TimerCube {
min-height: 260px;
}
}

94
brice/www/css/style.css Normal file
View File

@@ -0,0 +1,94 @@
html, body {
height: 100%;
margin: 0;
}
.scene{
display: none;
width: 100%;
height: 100vh;
background: linear-gradient(to right, #583305,#8A5009 );
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.button{
position: absolute;
width: 50%;
height: 50vh;
top: 50%;
}
#black_button{
right: 0%;
background: linear-gradient(to top, #A55B00, #583305);
}
#white_button{
right: 50%;
background: linear-gradient(to top , #E9BB82, #583305);
}
.click{
filter:brightness(0.9);
}
.TextClock{
position: absolute;
font-family: "Dancing Script", cursive;
font-optical-sizing: auto;
font-weight: 0;
font-style: normal;
font-size: 3vw;
}
#TimeBlack{
left: 70%;
top: 35%;
}
#TimeWhite{
top: 35%;
left: 20%;
}
#BlockTime{
top:5%;
left: 35%;
}
#MoveLeftWhite{
left: 13%;
top: 25%
}
#MoveLeftBlack{
left: 63%;
top: 25%;
}
#BlockType{
top:15%;
left: 48%;
}
body::before {
content: "";
position: fixed;
top: 0;
width: 50%;
height: 100%;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease, left 1s ease, right 1s ease;
background: radial-gradient(
circle at center,
rgba(255, 255, 0, 0.6),
transparent 50%
);
}
/* Glow à gauche */
.glow-left::before {
left: 0;
opacity: 1;
}
/* Glow à droite */
.glow-right::before {
right: 0;
opacity: 1;
}

BIN
brice/www/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

91
brice/www/index.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="js/scene.js"></script>
<script src="cordova.js"></script>
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/clock-scene.css">
<link rel="stylesheet" href="css/cube-scene.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Cinzel:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="sceneClock" class="scene scene-clock">
<div class="clock-shell">
<div class="clock-topbar">
<h1 id="BlockTime" class="TextClock clock-status">Temps restant Block : 3:00</h1>
<h1 id="BlockType" class="TextClock clock-mode">Block -</h1>
</div>
<div class="clock-board">
<section class="player-zone player-zone-white">
<div class="player-badge">BLANC</div>
<div class="clock-panel clock-panel-white">
<div class="button" id="white_button" aria-label="White clock button"></div>
<div class="panel-light"></div>
<h1 id="TimeWhite" class="TextClock clock-time">10:00</h1>
<h1 id="MoveLeftWhite" class="TextClock clock-moves">Coup restant Blanc : 8</h1>
</div>
</section>
<div class="clock-divider"></div>
<section class="player-zone player-zone-black">
<div class="player-badge player-badge-dark">NOIR</div>
<div class="clock-panel clock-panel-black">
<div class="button" id="black_button" aria-label="Black clock button"></div>
<div class="panel-light"></div>
<h1 id="TimeBlack" class="TextClock clock-time">10:00</h1>
<h1 id="MoveLeftBlack" class="TextClock clock-moves">Coup restant Noir : 8</h1>
</div>
</section>
</div>
</div>
</div>
<div class="scene scene-cube" id="sceneCube">
<div class="cube-shell">
<div class="cube-topbar">
<h1 class="TimerText cube-title">Phase Cube</h1>
<h1 class="TimerText" id="BlockTypeTimer">Block -</h1>
<p class="cube-subtitle">Maintiens 2 secondes pour lancer le timer, puis touche de nouveau quand le cube est termine.</p>
</div>
<div class="cube-board">
<section class="cube-lane cube-lane-white">
<div class="cube-badge">BLANC</div>
<div class="TimerCube cube-pad" id="TimerWhite">
<div class="cube-grid"></div>
<h1 class="TimerText" id="TextWhite">Temps au cube Blanc : 0:00</h1>
<p class="cube-hint">Appui long pour demarrer</p>
</div>
</section>
<div class="cube-divider"></div>
<section class="cube-lane cube-lane-black">
<div class="cube-badge cube-badge-dark">NOIR</div>
<div class="TimerCube cube-pad cube-pad-dark" id="TimerBlack">
<div class="cube-grid"></div>
<h1 class="TimerText" id="TextBlack">Temps au cube Noir : 0:00</h1>
<p class="cube-hint">Touchez quand le cube est fini</p>
</div>
</section>
</div>
</div>
</div>
<script src="js/ClockFrontEnd.js"></script>
<script src="js/TimerFrontEnd.js"></script>
<script src="js/backend.js"></script>
<script src="js/appload.js"></script>
</body>
</html>

View File

@@ -0,0 +1,87 @@
let white = document.getElementById("white_button");
let black = document.getElementById("black_button");
let white_time = document.getElementById("TimeWhite")
let black_time = document.getElementById("TimeBlack")
let black_move_left = document.getElementById("MoveLeftBlack")
let white_move_left = document.getElementById("MoveLeftWhite")
let BlockType = document.getElementById("BlockType")
let BlockTime = document.getElementById("BlockTime")
let trais = true
function msToMinSec(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function toggle_trais(){
trais = !trais
if (trais){
showGlowRight()
}else{
showGlowLeft()
}
}
function change_move_left_white(number){
white_move_left.innerText = `Coup restant Blanc : ${number}`
}
function change_move_left_black(number){
black_move_left.innerText = `Coup restant Noir : ${number}`
}
function change_time_block(Time){
BlockTime.innerText = `Temps restant Block : ${msToMinSec(Time)}`
}
function set_block_type(type){
// true = +
// false = -
if (type){
BlockType.innerText = "Block +"
}else{
BlockType.innerText = "Block -"
}
}
function change_time_white(Time){
white_time.innerText = msToMinSec(Time)
}
function change_time_black(Time){
black_time.innerText = msToMinSec(Time)
}
white.addEventListener("pointerdown", () => {
if (!trais) white.classList.add("click");
if (!has_start) start()
else white_touch()
});
white.addEventListener("pointerup", () => {
white.classList.remove("click");
});
black.addEventListener("pointerdown", () => {
if (trais )black.classList.add("click");
black_touch()
});
black.addEventListener("pointerup", () => {
black.classList.remove("click");
});
function showGlowLeft() {
document.body.classList.remove("glow-right");
document.body.classList.add("glow-left");
}
function showGlowRight() {
document.body.classList.remove("glow-left");
document.body.classList.add("glow-right");
}
function hideGlow() {
document.body.classList.remove("glow-left", "glow-right");
}

View File

@@ -0,0 +1,35 @@
let white_Timer = document.getElementById("TimerWhite")
let black_Timer = document.getElementById("TimerBlack")
let white_Time_Timer = document.getElementById("TextWhite")
let black_Time_Timer = document.getElementById("TextBlack")
let TimerBlockType = document.getElementById("BlockTypeTimer")
function Set_block_type_Timer(type){
if (type){
TimerBlockType.innerText = "Block +"
}else{
TimerBlockType.innerText = "Block -"
}
}
function Set_White_Time_Cube(Time){
white_Time_Timer.innerText = `Temps au cube Blanc : ${msToMinSec(Time)}`
}
function Set_Black_Time_Cube(Time){
black_Time_Timer.innerText = `Temps au cube Noir : ${msToMinSec(Time)}`
}
white_Timer.addEventListener("pointerdown", () => {
white_timer_touch();
});
white_Timer.addEventListener("pointerup", () => {
white_timer_release();
});
black_Timer.addEventListener("pointerdown", () => {
black_timer_touch();
});
black_Timer.addEventListener("pointerup", () => {
black_timer_release();
});

3
brice/www/js/appload.js Normal file
View File

@@ -0,0 +1,3 @@
load_Clock_scene()
update()

239
brice/www/js/backend.js Normal file
View File

@@ -0,0 +1,239 @@
let White = {
moveLeft : 0,
Time : 0,
TimeStartMove : 0
}
let Black = {
moveLeft : 0,
Time : 0,
TimeStartMove : 0
}
let has_start = false
Block = {
Type : false,
Time : 0,
last_time : 0
}
Timer = {
White_Has_started : false,
Black_Has_started : false,
Black_time : 0,
White_time : 0,
White_start_time : 0,
Black_start_time : 0,
coldown_White : 0,
coldown_Black : 0,
coldown_Start_White : 0,
coldown_Start_Black : 0,
White_Has_Finish : false,
Black_Has_Finish : false
}
config = {
StartTimeWhite : 600000,
StartTimeBlack:600000,
StartMoveLeft : 8,
BlockTime : 180000,
last_block : true,
trais : false,
MaxIncrement : 120000
}
function load_Clock_scene(){
has_start = false
hide_scene("Cube")
inizalize_clock()
show_scene("Clock")
inizalize_clock()
}
function inizalize_clock(){
trais = !config.trais
toggle_trais()
White.Time = config.StartTimeWhite
White.moveLeft = config.StartMoveLeft
Black.Time = config.StartTimeBlack
Black.moveLeft = config.StartMoveLeft
Block.Time = config.BlockTime
Block.Type = !config.last_block
change_move_left_black(Black.moveLeft)
change_move_left_white(White.moveLeft)
change_time_black(Black.Time)
change_time_white(White.Time)
change_time_block(Block.Time)
set_block_type(Block.Type)
}
function start(){
White.TimeStartMove = Date.now()
Black.TimeStartMove = Date.now()
Block.last_time = Date.now()
has_start = true
}
function load_cube_scene(){
has_start = false
config.StartTimeWhite = White.Time
config.StartTimeBlack = Black.Time
config.last_block = Block.Type
Timer = {
White_Has_started : false,
Black_Has_started : false,
Black_time : 0,
White_time : 0,
White_start_time : 0,
Black_start_time : 0,
coldown_White : 0,
coldown_Black : 0,
coldown_Start_White : 0,
coldown_Start_Black : 0,
White_Has_Finish : false,
Black_Has_Finish : false
}
Set_block_type_Timer(config.last_block)
Set_White_Time_Cube(0)
Set_Black_Time_Cube(0)
hide_scene("Clock")
hideGlow()
show_scene("Cube")
}
function black_timer_touch(){
if (!Timer.Black_Has_started){
Timer.coldown_Start_Black = Date.now()
black_Timer.classList.add("RedClick")
}else if(!Timer.Black_Has_Finish){
Timer.Black_Has_Finish = true
black_Timer.classList.add("GreenClick")
}
}
function black_timer_release(){
if (Timer.coldown_Black > 2000) {
Timer.Black_Has_started = true
Timer.Black_start_time = Date.now()
black_Timer.classList.remove("GreenClick")
}else{
black_Timer.classList.remove("RedClick")
Timer.coldown_Start_Black = 0;
}
}
function white_timer_touch(){
if (!Timer.White_Has_started){
Timer.coldown_Start_White = Date.now()
white_Timer.classList.add("RedClick")
}else if(!Timer.White_Has_Finish){
Timer.White_Has_Finish = true
white_Timer.classList.add("GreenClick")
}
}
function white_timer_release(){
if (Timer.coldown_White > 2000) {
Timer.White_Has_started = true
Timer.White_start_time = Date.now()
white_Timer.classList.remove("GreenClick")
}else{
white_Timer.classList.remove("RedClick")
Timer.coldown_Start_White = 0;
}
}
function white_touch(){
if (trais) return null
if (!has_start) return null
toggle_trais()
Black.TimeStartMove = Date.now()
White.moveLeft -= 1
change_move_left_white(White.moveLeft)
}
function black_touch(){
if (!trais) return null
if (!has_start) return null
Black.moveLeft -= 1
if (Black.moveLeft == 0){
load_cube_scene()
}else{
toggle_trais()
White.TimeStartMove = Date.now()
change_move_left_black(Black.moveLeft)
}
}
function update() {
if (last_scene == "Clock" && has_start){
if (!trais) {// trais au blanc
White.Time -= (Date.now() - White.TimeStartMove)
White.TimeStartMove = Date.now()
}else{
Black.Time -= (Date.now() - Black.TimeStartMove)
Black.TimeStartMove = Date.now()
}
Block.Time -= (Date.now() - Block.last_time)
Block.last_time = Date.now()
if (Block.Time <= 0){
load_cube_scene()
}
change_time_white(White.Time)
change_time_black(Black.Time)
change_time_block(Block.Time)
}else if(last_scene == "Cube"){
if (!Timer.Black_Has_started){ // noire a pas commencer le cube
if (Timer.coldown_Start_Black != 0){ // coldown lancer
Timer.coldown_Black = Date.now() - Timer.coldown_Start_Black
if (Timer.coldown_Black > 2000){
black_Timer.classList.remove("RedClick")
black_Timer.classList.add("GreenClick")
}
}
}else if(!Timer.Black_Has_Finish){
Timer.Black_time = Date.now() - Timer.Black_start_time
Set_Black_Time_Cube(Timer.Black_time)
}
if (!Timer.White_Has_started){ // blanc a pas commencer le cube
if (Timer.coldown_Start_White != 0){ // coldown lancer
Timer.coldown_White = Date.now() - Timer.coldown_Start_White
if (Timer.coldown_White > 2000){
white_Timer.classList.remove("RedClick")
white_Timer.classList.add("GreenClick")
}
}
}else if (!Timer.White_Has_Finish){
Timer.White_time = Date.now() - Timer.White_start_time
Set_White_Time_Cube(Timer.White_time)
}
if (Timer.Black_Has_Finish && Timer.White_Has_Finish){
if (config.last_block){
config.StartTimeWhite += Timer.Black_time
config.StartTimeBlack += Timer.Black_time
}else{
config.StartTimeWhite -= Timer.White_time
config.StartTimeBlack -= Timer.Black_time
}
load_Clock_scene()
}
}
requestAnimationFrame(update);
}

13
brice/www/js/scene.js Normal file
View File

@@ -0,0 +1,13 @@
let last_scene = ""
function show_scene(name){
scene = document.getElementById("scene" + name);
console.log(scene)
last_scene = name
scene.style.display = "block";
}
function hide_scene(name){
scene = document.getElementById("scene" + name);
scene.style.display = "none";
}

15
brice/www/manifest.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "Mon Timer",
"short_name": "ChessCubingClock",
"start_url": "index.html",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "img/logo.png",
"sizes": "192x192",
"type": "image/png"
}
]
}

View File

@@ -51,10 +51,8 @@
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
};
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = window.__CHESSCUBING_ASSET_URL__("styles.css");
document.head.append(stylesheet);
const stylesheetHref = window.__CHESSCUBING_ASSET_URL__("styles.css");
document.write(`<link rel="stylesheet" href="${stylesheetHref}" />`);
})();
</script>
</head>

View File

@@ -51,10 +51,8 @@
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
};
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = window.__CHESSCUBING_ASSET_URL__("styles.css");
document.head.append(stylesheet);
const stylesheetHref = window.__CHESSCUBING_ASSET_URL__("styles.css");
document.write(`<link rel="stylesheet" href="${stylesheetHref}" />`);
})();
</script>
</head>

682
doc/api-utilisateurs.md Normal file
View File

@@ -0,0 +1,682 @@
# API utilisateurs pour applications externes
Ce document decrit les endpoints exposes par ChessCubing pour authentifier un utilisateur, recuperer son profil, rechercher d'autres joueurs, gerer les relations sociales, et administrer les comptes.
Les exemples ci-dessous utilisent `http://localhost:8080` comme URL locale. En production, remplacez cette base par l'URL de votre instance.
## Vue d'ensemble
- API HTTP REST en JSON.
- Authentification par cookie de session, pas par bearer token applicatif.
- Cookie principal : `chesscubing.auth`
- Duree de vie du cookie : 30 jours, avec renouvellement glissant.
- Les dates sont retournees en UTC, au format ISO 8601.
- Les applications navigateur sur un autre domaine ne peuvent pas consommer cette API directement aujourd'hui : aucune politique CORS n'est configuree cote serveur. Pour une integration externe, privilegier un appel serveur a serveur, ou ajouter CORS dans l'application.
## Authentification
### Principe
1. L'application externe appelle `POST /api/auth/login`.
2. Le serveur renvoie un cookie HTTP-only.
3. Les appels suivants doivent reutiliser ce cookie.
Exemple `curl` avec conservation du cookie :
```bash
curl -sS -c cookies.txt \
-H "Content-Type: application/json" \
-d '{"username":"kiki","password":"motdepasse"}' \
http://localhost:8080/api/auth/login
```
Puis reutilisation du cookie :
```bash
curl -sS -b cookies.txt http://localhost:8080/api/users/me
```
### POST /api/auth/login
Authentifie un utilisateur Keycloak, cree si besoin son profil site, puis ouvre une session cookie.
Requete :
```json
{
"username": "kiki",
"password": "motdepasse"
}
```
Reponse `200 OK` :
```json
{
"isAuthenticated": true,
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
"username": "kiki",
"name": "Christophe JEANNEROT",
"email": "christophe@jeannerot.fr",
"roles": ["player", "admin"]
}
```
Erreurs frequentes :
- `400 Bad Request` si `username` ou `password` sont absents.
- `401 Unauthorized` si les identifiants sont invalides.
- `5xx` ou autre code issu de Keycloak si l'authentification amont est indisponible.
### POST /api/auth/register
Cree un compte Keycloak, attribue le role `player`, connecte l'utilisateur et initialise son profil site.
Requete :
```json
{
"username": "kiki",
"email": "christophe@jeannerot.fr",
"password": "motdepasse123",
"confirmPassword": "motdepasse123",
"firstName": "Christophe",
"lastName": "JEANNEROT"
}
```
Reponse `200 OK` :
```json
{
"isAuthenticated": true,
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
"username": "kiki",
"name": "Christophe JEANNEROT",
"email": "christophe@jeannerot.fr",
"roles": ["player"]
}
```
Erreurs frequentes :
- `400 Bad Request` si `username`, `email` ou `password` sont absents.
- `400 Bad Request` si `password` et `confirmPassword` different.
- `409 Conflict` si le nom d'utilisateur ou l'email existe deja.
### GET /api/auth/session
Retourne l'etat de session courant. Cette route est publique.
Si l'utilisateur est connecte :
```json
{
"isAuthenticated": true,
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
"username": "kiki",
"name": "Christophe JEANNEROT",
"email": "christophe@jeannerot.fr",
"roles": ["player", "admin"]
}
```
Si l'utilisateur n'est pas connecte :
```json
{
"isAuthenticated": false,
"subject": null,
"username": null,
"name": null,
"email": null,
"roles": []
}
```
### POST /api/auth/logout
Ferme la session courante et vide le cookie d'authentification.
Reponse `200 OK` :
```json
{
"isAuthenticated": false,
"subject": null,
"username": null,
"name": null,
"email": null,
"roles": []
}
```
### GET /api/auth/logout/browser
Equivalent navigateur de la deconnexion. Cette route supprime le cookie puis redirige vers `/index.html`.
## Profil du compte courant
Ces routes permettent a une application connectee de recuperer et modifier le profil site de l'utilisateur courant.
### GET /api/users/me
Necessite une session authentifiee.
Reponse `200 OK` :
```json
{
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
"username": "kiki",
"email": "christophe@jeannerot.fr",
"displayName": "Christophe JEANNEROT",
"club": "ChessCubing Arena",
"city": "Vercel Villedieu le camp",
"preferredFormat": "Les deux",
"favoriteCube": "GAN 12",
"bio": "Joueur et organisateur.",
"createdUtc": "2026-04-14T18:04:55.0000000Z",
"updatedUtc": "2026-04-14T18:05:12.0000000Z"
}
```
Comportement notable :
- Si le profil site n'existe pas encore, il est cree automatiquement.
- `401 Unauthorized` si aucun cookie valide n'est fourni.
### PUT /api/users/me
Met a jour uniquement le profil site de l'utilisateur courant.
Requete :
```json
{
"displayName": "Christophe JEANNEROT",
"club": "ChessCubing Arena",
"city": "Vercel Villedieu le camp",
"preferredFormat": "Les deux",
"favoriteCube": "GAN 12",
"bio": "Joueur et organisateur."
}
```
Reponse `200 OK` : meme structure que `GET /api/users/me`.
Contraintes de validation :
- `displayName`, `club`, `city`, `favoriteCube` : 120 caracteres max.
- `bio` : 1200 caracteres max.
- `preferredFormat` : `Twice`, `Time` ou `Les deux`.
Erreurs frequentes :
- `400 Bad Request` si la valeur de `preferredFormat` est invalide.
- `400 Bad Request` si une longueur maximale est depassee.
- `401 Unauthorized` sans session.
### GET /api/users/me/stats
Retourne les statistiques du compte courant et ses derniers matchs enregistres.
Reponse `200 OK` :
```json
{
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
"currentElo": 1216,
"rankedGames": 1,
"casualGames": 2,
"wins": 2,
"losses": 1,
"stoppedGames": 0,
"whiteWins": 1,
"blackWins": 1,
"whiteLosses": 1,
"blackLosses": 0,
"totalMoves": 34,
"totalCubeRounds": 6,
"bestCubeTimeMs": 8420,
"averageCubeTimeMs": 11530,
"lastMatchUtc": "2026-04-15T11:00:00.0000000Z",
"recentMatches": [
{
"matchId": "c1f4e44f5f5f4fe4b8f2bf1f8a634f9e",
"completedUtc": "2026-04-15T11:00:00.0000000Z",
"result": "white",
"mode": "twice",
"preset": "fast",
"matchLabel": "Rencontre ChessCubing",
"playerColor": "white",
"playerName": "Christophe JEANNEROT",
"opponentName": "Alex Martin",
"opponentSubject": "sub-ami-1",
"isRanked": true,
"isWin": true,
"isLoss": false,
"playerMoves": 6,
"opponentMoves": 6,
"cubeRounds": 2,
"playerBestCubeTimeMs": 8420,
"playerAverageCubeTimeMs": 10120,
"eloBefore": 1200,
"eloAfter": 1216,
"eloDelta": 16
}
]
}
```
### POST /api/users/me/stats/matches
Enregistre la fin d'une partie pour les statistiques joueur.
Important :
- le compte connecte doit correspondre a l'un des deux joueurs identifies dans le payload
- l'Elo n'est mis a jour que si `whiteSubject` et `blackSubject` sont tous les deux renseignes, distincts, et que le resultat est `white` ou `black`
- si une meme partie est envoyee deux fois avec le meme `matchId`, l'enregistrement reste idempotent
Requete :
```json
{
"matchId": "c1f4e44f5f5f4fe4b8f2bf1f8a634f9e",
"collaborationSessionId": "play-session-123",
"whiteSubject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
"whiteName": "Christophe JEANNEROT",
"blackSubject": "sub-ami-1",
"blackName": "Alex Martin",
"result": "white",
"mode": "twice",
"preset": "fast",
"matchLabel": "Rencontre ChessCubing",
"blockNumber": 2,
"whiteMoves": 6,
"blackMoves": 6,
"cubeRounds": [
{
"blockNumber": 1,
"number": 4,
"white": 8420,
"black": 11030
}
]
}
```
Reponse `200 OK` :
```json
{
"recorded": true,
"isDuplicate": false,
"isRanked": true,
"whiteEloAfter": 1216,
"blackEloAfter": 1184
}
```
## Recherche de joueurs et relations sociales
Ces routes necessitent toutes une session authentifiee.
### GET /api/social/overview
Retourne la vue sociale du compte courant :
- `friends`
- `receivedInvitations`
- `sentInvitations`
Reponse `200 OK` :
```json
{
"friends": [
{
"subject": "sub-ami-1",
"username": "alex",
"displayName": "Alex Martin",
"email": "alex@example.com",
"club": "Club A",
"city": "Paris",
"isOnline": true
}
],
"receivedInvitations": [
{
"invitationId": 12,
"subject": "sub-ami-2",
"username": "lea",
"displayName": "Lea Durand",
"email": "lea@example.com",
"isOnline": false,
"createdUtc": "2026-04-15T08:15:00.0000000Z"
}
],
"sentInvitations": [
{
"invitationId": 18,
"subject": "sub-ami-3",
"username": "nina",
"displayName": "Nina Bernard",
"email": "nina@example.com",
"isOnline": true,
"createdUtc": "2026-04-15T08:18:00.0000000Z"
}
]
}
```
### GET /api/social/search?query={texte}
Recherche des joueurs connus du site par nom d'utilisateur, nom affiche, club, ville ou email selon l'index applicatif.
Exemple :
```bash
curl -sS -b cookies.txt \
"http://localhost:8080/api/social/search?query=chri"
```
Reponse `200 OK` :
```json
[
{
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
"username": "kiki",
"displayName": "Christophe JEANNEROT",
"email": "christophe@jeannerot.fr",
"club": "ChessCubing Arena",
"city": "Vercel Villedieu le camp",
"isOnline": true,
"isFriend": false,
"hasSentInvitation": false,
"hasReceivedInvitation": true
}
]
```
Comportement notable :
- Si `query` est vide ou blanche, la route renvoie `[]`.
- La recherche exige au moins 2 caracteres utiles.
- Le resultat est limite a 12 utilisateurs.
Erreurs frequentes :
- `400 Bad Request` si `query` contient moins de 2 caracteres.
- `401 Unauthorized` sans session.
### POST /api/social/invitations
Envoie une invitation d'ami.
Requete :
```json
{
"targetSubject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28"
}
```
Reponse `204 No Content`
Erreurs frequentes :
- `400 Bad Request` si on tente de s'inviter soi-meme.
- `400 Bad Request` si le joueur cible est introuvable.
- `400 Bad Request` si le joueur est deja ami.
- `400 Bad Request` si une invitation entre les deux comptes existe deja.
### POST /api/social/invitations/{invitationId}/accept
Accepte une invitation recue.
Reponse `204 No Content`
Erreur frequente :
- `400 Bad Request` si l'invitation est introuvable ou deja traitee.
### POST /api/social/invitations/{invitationId}/decline
Refuse une invitation recue.
Reponse `204 No Content`
Erreur frequente :
- `400 Bad Request` si l'invitation est introuvable ou deja traitee.
### DELETE /api/social/invitations/{invitationId}
Annule une invitation envoyee.
Reponse `204 No Content`
Erreur frequente :
- `400 Bad Request` si l'invitation est introuvable ou deja retiree.
### DELETE /api/social/friends/{friendSubject}
Supprime une relation d'amitie.
Reponse `204 No Content`
## Administration des utilisateurs
Ces routes sont reservees aux sessions portant le role `admin`.
Sans ce role :
- `401 Unauthorized` si l'utilisateur n'est pas connecte.
- `403 Forbidden` si l'utilisateur est connecte mais n'a pas le role `admin`.
### GET /api/admin/users
Retourne toute la liste des utilisateurs connus, en fusionnant :
- les comptes Keycloak
- les profils site MySQL
La reponse est triee par activite recente, puis par nom d'utilisateur.
Reponse `200 OK` :
```json
[
{
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
"username": "kiki",
"email": "christophe@jeannerot.fr",
"identityDisplayName": "Christophe JEANNEROT",
"siteDisplayName": "Christophe JEANNEROT",
"isEnabled": true,
"isEmailVerified": false,
"hasSiteProfile": true,
"club": "ChessCubing Arena",
"city": "Vercel Villedieu le camp",
"preferredFormat": "Les deux",
"accountCreatedUtc": "2026-04-14T17:44:00.0000000Z",
"siteProfileUpdatedUtc": "2026-04-14T18:05:00.0000000Z"
}
]
```
### GET /api/admin/users/{subject}
Retourne le detail complet d'un utilisateur.
Reponse `200 OK` :
```json
{
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
"username": "kiki",
"email": "christophe@jeannerot.fr",
"firstName": "Christophe",
"lastName": "JEANNEROT",
"identityDisplayName": "Christophe JEANNEROT",
"isEnabled": true,
"isEmailVerified": false,
"accountCreatedUtc": "2026-04-14T17:44:00.0000000Z",
"hasSiteProfile": true,
"displayName": "Christophe JEANNEROT",
"club": "ChessCubing Arena",
"city": "Vercel Villedieu le camp",
"preferredFormat": "Les deux",
"favoriteCube": "GAN 12",
"bio": "Joueur et organisateur.",
"siteProfileCreatedUtc": "2026-04-14T18:04:00.0000000Z",
"siteProfileUpdatedUtc": "2026-04-14T18:05:00.0000000Z"
}
```
Erreurs frequentes :
- `404 Not Found` si l'utilisateur n'existe pas dans Keycloak.
### POST /api/admin/users
Cree un utilisateur, son mot de passe, puis son profil site.
Requete :
```json
{
"username": "nouveau",
"email": "nouveau@example.com",
"password": "motdepasse123",
"confirmPassword": "motdepasse123",
"firstName": "Nouveau",
"lastName": "Joueur",
"isEnabled": true,
"isEmailVerified": false,
"displayName": "Nouveau Joueur",
"club": "Club A",
"city": "Paris",
"preferredFormat": "Time",
"favoriteCube": "Moyu RS3M",
"bio": "Nouveau profil"
}
```
Reponse `201 Created` :
- En-tete `Location: /api/admin/users/{subject}`
- Corps : meme structure que `GET /api/admin/users/{subject}`
Contraintes de validation :
- `username` obligatoire, max 120 caracteres.
- `email` facultatif mais valide si fourni, max 255 caracteres.
- `password` obligatoire, minimum 8 caracteres.
- `confirmPassword` doit correspondre a `password`.
- `firstName`, `lastName`, `displayName`, `club`, `city`, `favoriteCube` : 120 caracteres max.
- `bio` : 1200 caracteres max.
- `preferredFormat` : `Twice`, `Time` ou `Les deux`.
Erreurs frequentes :
- `400 Bad Request` sur validation metier.
- `409 Conflict` si le nom d'utilisateur ou l'email existe deja.
### PUT /api/admin/users/{subject}
Met a jour le compte Keycloak et le profil site.
Important :
- Le `username` n'est pas modifiable via cette route.
Requete :
```json
{
"email": "christophe@jeannerot.fr",
"firstName": "Christophe",
"lastName": "JEANNEROT",
"isEnabled": true,
"isEmailVerified": true,
"displayName": "Christophe JEANNEROT",
"club": "ChessCubing Arena",
"city": "Vercel Villedieu le camp",
"preferredFormat": "Les deux",
"favoriteCube": "GAN 12",
"bio": "Joueur et organisateur."
}
```
Reponse `200 OK` : meme structure que `GET /api/admin/users/{subject}`.
Erreurs frequentes :
- `400 Bad Request` sur validation metier.
- `404 Not Found` si l'utilisateur est introuvable.
- `409 Conflict` si l'email est deja utilise par un autre compte.
### DELETE /api/admin/users/{subject}
Supprime :
- le compte Keycloak
- le profil site
- les relations sociales et invitations associees
Reponse `204 No Content`
Erreur frequente :
- `404 Not Found` si l'utilisateur est introuvable.
## Temps reel via SignalR
Le hub `/hubs/social` est protege par authentification et permet :
- la presence en ligne
- les invitations de partie entre amis
- la synchronisation d'une partie entre plusieurs devices
Les principaux messages/calls utilises sont :
- appel client -> serveur `RequestPresenceSnapshot`
- appel client -> serveur `SendPlayInvite(recipientSubject, recipientColor)`
- appel client -> serveur `RespondToPlayInvite(inviteId, accept)`
- appel client -> serveur `CancelPlayInvite(inviteId)`
- appel client -> serveur `JoinPlaySession(sessionId)`
- appel client -> serveur `LeavePlaySession(sessionId)`
- appel client -> serveur `PublishMatchState(sessionId, matchJson, route)`
- evenement serveur -> client `PresenceSnapshot`
- evenement serveur -> client `PresenceChanged`
- evenement serveur -> client `PlayInviteUpdated`
- evenement serveur -> client `PlayInviteAccepted`
- evenement serveur -> client `PlayInviteClosed`
- evenement serveur -> client `CollaborativeMatchStateUpdated`
Pour un integrateur qui veut seulement recuperer les informations utilisateur, les endpoints REST de ce document sont generalement suffisants. Le hub devient utile des qu'il faut suivre la presence, les invitations ou l'etat partage d'une partie en temps reel.
## Codes de reponse a prevoir
- `200 OK` : lecture ou action aboutie avec corps JSON.
- `201 Created` : creation d'utilisateur admin.
- `204 No Content` : action aboutie sans corps.
- `400 Bad Request` : erreur de validation ou session incomplete.
- `401 Unauthorized` : pas de session valide.
- `403 Forbidden` : session valide mais role insuffisant.
- `404 Not Found` : ressource introuvable, principalement cote admin.
- `409 Conflict` : doublon sur `username` ou `email`.
## Recommandations d'integration
- Stocker et rejouer le cookie de session si l'application externe fonctionne en backend.
- Eviter les appels directs depuis un frontend sur un autre domaine tant que CORS n'est pas configure.
- Utiliser `GET /api/auth/session` pour verifier rapidement l'etat de connexion et les roles.
- Utiliser `GET /api/users/me` pour recuperer le profil courant.
- Utiliser `GET /api/social/search` si l'objectif est de rechercher des joueurs connectes au site.
- Utiliser `GET /api/admin/users` uniquement pour une application d'administration portant le role `admin`.

View File

@@ -1,7 +1,118 @@
services:
web:
build: .
build:
context: .
dockerfile: Dockerfile
container_name: chesscubing-web
depends_on:
auth:
condition: service_started
keycloak:
condition: service_started
ports:
- "8080:80"
- "${WEB_PORT:-8080}:80"
restart: unless-stopped
auth:
build:
context: .
dockerfile: Dockerfile.auth
container_name: chesscubing-auth
environment:
ASPNETCORE_URLS: http://+:8080
KEYCLOAK_BASE_URL: http://keycloak:8080/auth
KEYCLOAK_REALM: chesscubing
KEYCLOAK_CLIENT_ID: chesscubing-web
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
SITE_DB_HOST: mysql
SITE_DB_PORT: 3306
SITE_DB_NAME: ${SITE_DB_NAME:-chesscubing_site}
SITE_DB_USER: ${SITE_DB_USER:-chesscubing}
SITE_DB_PASSWORD: ${SITE_DB_PASSWORD:-chesscubing}
depends_on:
keycloak-init:
condition: service_completed_successfully
keycloak:
condition: service_started
mysql:
condition: service_healthy
restart: unless-stopped
keycloak:
image: quay.io/keycloak/keycloak:26.1.2
container_name: chesscubing-keycloak
command: ["start-dev", "--import-realm"]
environment:
KC_DB: postgres
KC_DB_URL_HOST: postgres
KC_DB_URL_PORT: 5432
KC_DB_URL_DATABASE: ${KEYCLOAK_DB_NAME:-keycloak}
KC_DB_USERNAME: ${KEYCLOAK_DB_USER:-keycloak}
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak}
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
KC_PROXY_HEADERS: xforwarded
KC_HOSTNAME: "${PUBLIC_BASE_URL:-http://localhost:8080}/auth"
KC_HTTP_RELATIVE_PATH: /auth
KC_HOSTNAME_STRICT: "false"
volumes:
- ./keycloak/realm:/opt/keycloak/data/import:ro
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
keycloak-init:
image: quay.io/keycloak/keycloak:26.1.2
container_name: chesscubing-keycloak-init
entrypoint: ["/bin/sh", "/opt/keycloak/data/scripts/init-config.sh"]
environment:
KEYCLOAK_URL: http://keycloak:8080/auth
KEYCLOAK_REALM: chesscubing
KEYCLOAK_CLIENT_ID: chesscubing-web
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
volumes:
- ./keycloak/scripts:/opt/keycloak/data/scripts:ro
depends_on:
keycloak:
condition: service_started
restart: "no"
postgres:
image: postgres:16-alpine
container_name: chesscubing-keycloak-db
environment:
POSTGRES_DB: ${KEYCLOAK_DB_NAME:-keycloak}
POSTGRES_USER: ${KEYCLOAK_DB_USER:-keycloak}
POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak}
volumes:
- keycloak-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${KEYCLOAK_DB_USER:-keycloak} -d ${KEYCLOAK_DB_NAME:-keycloak}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
mysql:
image: mysql:8.4
container_name: chesscubing-site-db
environment:
MYSQL_DATABASE: ${SITE_DB_NAME:-chesscubing_site}
MYSQL_USER: ${SITE_DB_USER:-chesscubing}
MYSQL_PASSWORD: ${SITE_DB_PASSWORD:-chesscubing}
MYSQL_ROOT_PASSWORD: ${SITE_DB_ROOT_PASSWORD:-change-me}
volumes:
- site-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
interval: 10s
timeout: 5s
retries: 8
restart: unless-stopped
volumes:
keycloak-postgres:
site-mysql-data:

View File

@@ -43,6 +43,7 @@
<a class="button primary" href="application.html">Ouvrir l'application</a>
<a class="button secondary" href="reglement.html">Lire le règlement</a>
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
<a class="button ghost" href="/brice/">Ouvrir l'appli de Brice</a>
</div>
</div>

View File

@@ -35,6 +35,8 @@ Variables d'environnement reconnues :
CHESSCUBING_GIT_BRANCH
CHESSCUBING_ETHAN_REPO_URL
CHESSCUBING_ETHAN_GIT_BRANCH
CHESSCUBING_BRICE_REPO_URL
CHESSCUBING_BRICE_GIT_BRANCH
EOF
}
@@ -111,6 +113,8 @@ ROOTFS_STORAGE="${CHESSCUBING_ROOTFS_STORAGE:-}"
LXC_PASSWORD="${CHESSCUBING_LXC_PASSWORD:-}"
ETHAN_REPO_URL="${CHESSCUBING_ETHAN_REPO_URL:-https://git.jeannerot.fr/Mineloulou/Chesscubing.git}"
ETHAN_REPO_BRANCH="${CHESSCUBING_ETHAN_GIT_BRANCH:-main}"
BRICE_REPO_URL="${CHESSCUBING_BRICE_REPO_URL:-https://git.jeannerot.fr/Lescratcheur/ChessCubing.git}"
BRICE_REPO_BRANCH="${CHESSCUBING_BRICE_GIT_BRANCH:-main}"
if [[ -z "$LOCAL_MODE" && -z "$PROXMOX_HOST" ]]; then
if have_cmd pct && have_cmd pveam; then
@@ -165,6 +169,8 @@ cmd=(
--branch "$REPO_BRANCH"
--ethan-repo-url "$ETHAN_REPO_URL"
--ethan-branch "$ETHAN_REPO_BRANCH"
--brice-repo-url "$BRICE_REPO_URL"
--brice-branch "$BRICE_REPO_BRANCH"
)
if [[ "$LOCAL_MODE" == "1" ]]; then

View File

@@ -0,0 +1,62 @@
{
"realm": "chesscubing",
"enabled": true,
"displayName": "ChessCubing",
"registrationAllowed": true,
"rememberMe": true,
"resetPasswordAllowed": true,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"editUsernameAllowed": false,
"sslRequired": "external",
"roles": {
"realm": [
{
"name": "admin",
"description": "Administrateur ChessCubing"
},
{
"name": "organizer",
"description": "Organisateur de rencontre"
},
{
"name": "player",
"description": "Joueur ChessCubing"
}
]
},
"clients": [
{
"clientId": "chesscubing-web",
"name": "ChessCubing Web",
"description": "Client OIDC public pour l'application Blazor WebAssembly.",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"implicitFlowEnabled": false,
"frontchannelLogout": true,
"rootUrl": "http://localhost:8080/",
"baseUrl": "http://localhost:8080/",
"redirectUris": [
"http://localhost:8080/*"
],
"webOrigins": [
"http://localhost:8080"
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8080/*"
},
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email"
]
}
]
}

View File

@@ -0,0 +1,43 @@
#!/bin/sh
set -eu
KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak:8080/auth}"
KEYCLOAK_REALM="${KEYCLOAK_REALM:-chesscubing}"
KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-chesscubing-web}"
KEYCLOAK_ADMIN_USERNAME="${KEYCLOAK_ADMIN_USERNAME:-admin}"
KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-admin}"
echo "Attente de Keycloak..."
until /opt/keycloak/bin/kcadm.sh config credentials \
--server "$KEYCLOAK_URL" \
--realm master \
--user "$KEYCLOAK_ADMIN_USERNAME" \
--password "$KEYCLOAK_ADMIN_PASSWORD" >/dev/null 2>&1; do
sleep 2
done
CLIENT_INTERNAL_ID="$(
/opt/keycloak/bin/kcadm.sh get clients \
-r "$KEYCLOAK_REALM" \
-q clientId="$KEYCLOAK_CLIENT_ID" \
--fields id \
--format csv \
--noquotes | tail -n 1
)"
if [ -z "$CLIENT_INTERNAL_ID" ]; then
echo "Client Keycloak introuvable: $KEYCLOAK_CLIENT_ID"
exit 1
fi
/opt/keycloak/bin/kcadm.sh update "clients/$CLIENT_INTERNAL_ID" \
-r "$KEYCLOAK_REALM" \
-s directAccessGrantsEnabled=true \
-s standardFlowEnabled=true \
-s publicClient=true >/dev/null
/opt/keycloak/bin/kcadm.sh update "realms/$KEYCLOAK_REALM" \
-s registrationAllowed=true \
-s loginWithEmailAllowed=true >/dev/null
echo "Configuration Keycloak synchronisee."

View File

@@ -13,6 +13,46 @@ server {
try_files $uri $uri/ /ethan/index.html;
}
location = /brice {
return 301 $scheme://$http_host/brice/;
}
location /brice/ {
try_files $uri $uri/ /brice/index.html;
}
location /auth/ {
proxy_http_version 1.1;
proxy_pass http://keycloak:8080/auth/;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/ {
proxy_http_version 1.1;
proxy_pass http://auth:8080/api/;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /hubs/ {
proxy_http_version 1.1;
proxy_pass http://auth:8080/hubs/;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -25,15 +25,22 @@ Options principales:
--gateway Passerelle si IP statique
--bridge Bridge reseau Proxmox (defaut: vmbr0)
--cores Nombre de vCPU du LXC (defaut: 2)
--memory Memoire RAM en Mo (defaut: 1024)
--swap Swap en Mo (defaut: 512)
--disk-gb Taille disque du LXC en Go (defaut: 6)
--memory Memoire RAM en Mo (defaut: 4096)
--swap Swap en Mo (defaut: 1024)
--disk-gb Taille disque du LXC en Go (defaut: 16)
--template-storage Stockage Proxmox pour les templates
--rootfs-storage Stockage Proxmox pour le disque LXC
--repo-url Depot Git a deployer
--branch Branche Git a deployer (defaut: main)
--ethan-repo-url Depot Git de l'application Ethan
--ethan-branch Branche Git de l'application Ethan (defaut: main)
--brice-repo-url Depot Git de l'application Brice
--brice-branch Branche Git de l'application Brice (defaut: main)
--public-base-url URL publique du site (ex: http://jeu.example.com)
--web-port Port HTTP expose dans le LXC (defaut: 80)
--keycloak-admin-user Utilisateur admin Keycloak (defaut: admin)
--keycloak-admin-password
Mot de passe admin Keycloak. Genere si absent
--lxc-password Mot de passe root du LXC. Genere si absent
-h, --help Affiche cette aide
@@ -66,15 +73,21 @@ LXC_IP="dhcp"
LXC_GATEWAY=""
LXC_BRIDGE="vmbr0"
LXC_CORES="2"
LXC_MEMORY="1024"
LXC_SWAP="512"
LXC_DISK_GB="6"
LXC_MEMORY="4096"
LXC_SWAP="1024"
LXC_DISK_GB="16"
TEMPLATE_STORAGE=""
ROOTFS_STORAGE=""
REPO_URL="https://git.jeannerot.fr/christophe/chesscubing.git"
REPO_BRANCH="main"
ETHAN_REPO_URL="https://git.jeannerot.fr/Mineloulou/Chesscubing.git"
ETHAN_REPO_BRANCH="main"
BRICE_REPO_URL="https://git.jeannerot.fr/Lescratcheur/ChessCubing.git"
BRICE_REPO_BRANCH="main"
PUBLIC_BASE_URL=""
WEB_PORT="80"
KEYCLOAK_ADMIN_USER="admin"
KEYCLOAK_ADMIN_PASSWORD=""
LXC_PASSWORD=""
while [[ $# -gt 0 ]]; do
@@ -159,6 +172,30 @@ while [[ $# -gt 0 ]]; do
ETHAN_REPO_BRANCH="${2:-}"
shift 2
;;
--brice-repo-url)
BRICE_REPO_URL="${2:-}"
shift 2
;;
--brice-branch)
BRICE_REPO_BRANCH="${2:-}"
shift 2
;;
--public-base-url)
PUBLIC_BASE_URL="${2:-}"
shift 2
;;
--web-port)
WEB_PORT="${2:-}"
shift 2
;;
--keycloak-admin-user)
KEYCLOAK_ADMIN_USER="${2:-}"
shift 2
;;
--keycloak-admin-password)
KEYCLOAK_ADMIN_PASSWORD="${2:-}"
shift 2
;;
--lxc-password)
LXC_PASSWORD="${2:-}"
shift 2
@@ -206,7 +243,13 @@ repo_url="$3"
repo_branch="$4"
ethan_repo_url="$5"
ethan_repo_branch="$6"
lxc_password="$7"
brice_repo_url="$7"
brice_repo_branch="$8"
lxc_password="$9"
public_base_url="${10}"
web_port="${11}"
keycloak_admin_user="${12}"
keycloak_admin_password="${13}"
die() {
printf 'Erreur: %s\n' "$*" >&2
@@ -325,7 +368,8 @@ pct create "$ctid" "$template_ref" \
--onboot 1 \
--ostype debian \
--password "$lxc_password" \
--unprivileged 1
--unprivileged 1 \
--features nesting=1,keyctl=1
pct start "$ctid"
@@ -339,21 +383,7 @@ done
pct exec "$ctid" -- true >/dev/null 2>&1 || die "Le LXC n'est pas joignable apres le demarrage."
printf 'Installation de nginx, git et rsync dans le conteneur...\n'
ct_exec "apt-get update && apt-get install -y ca-certificates git nginx rsync"
ct_exec "install -d -m 0755 /opt/chesscubing/repo /opt/chesscubing/ethan-repo /var/www/chesscubing/current"
printf 'Clonage du depot %s...\n' "$repo_url"
ct_exec "if [ ! -d /opt/chesscubing/repo/.git ]; then \
rm -rf /opt/chesscubing/repo/* /opt/chesscubing/repo/.[!.]* /opt/chesscubing/repo/..?* 2>/dev/null || true; \
git clone --branch '$repo_branch' --single-branch '$repo_url' /opt/chesscubing/repo; \
else \
cd /opt/chesscubing/repo && \
git fetch origin '$repo_branch' && \
if git show-ref --verify --quiet 'refs/heads/$repo_branch'; then git checkout '$repo_branch'; else git checkout -b '$repo_branch' --track 'origin/$repo_branch'; fi && \
git pull --ff-only origin '$repo_branch'; \
fi"
ct_exec "install -d -m 0755 /opt/chesscubing/repo /opt/chesscubing/ethan-repo /opt/chesscubing/brice-repo /opt/chesscubing/deploy /opt/chesscubing/config"
ct_exec "cat > /usr/local/bin/update-chesscubing <<'SCRIPT'
#!/usr/bin/env bash
@@ -363,10 +393,56 @@ trap 'printf \"Erreur: echec de la commande [%s] a la ligne %s.\\n\" \"\$BASH_CO
main_repo_dir='/opt/chesscubing/repo'
ethan_repo_dir='/opt/chesscubing/ethan-repo'
web_root='/var/www/chesscubing/current'
brice_repo_dir='/opt/chesscubing/brice-repo'
deploy_dir='/opt/chesscubing/deploy'
config_dir='/opt/chesscubing/config'
env_file=\"\$config_dir/chesscubing.env\"
main_branch=\"\${1:-${repo_branch}}\"
public_base_url_override=\"\${2:-}\"
web_port_override=\"\${3:-}\"
keycloak_admin_user_override=\"\${4:-}\"
keycloak_admin_password_override=\"\${5:-}\"
main_repo_url='${repo_url}'
ethan_repo_url='${ethan_repo_url}'
ethan_branch='${ethan_repo_branch}'
brice_repo_url='${brice_repo_url}'
brice_branch='${brice_repo_branch}'
random_secret() {
od -An -N24 -tx1 /dev/urandom | tr -d ' \n'
}
ensure_base_packages() {
apt-get update
apt-get install -y ca-certificates curl gpg git rsync
}
ensure_docker_stack() {
ensure_base_packages
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
fi
if [[ ! -f /etc/apt/sources.list.d/docker.list ]]; then
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian %s stable\n' \
\"\$(dpkg --print-architecture)\" \
\"\$(. /etc/os-release && printf '%s' \"\$VERSION_CODENAME\")\" > /etc/apt/sources.list.d/docker.list
fi
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
systemctl enable docker >/dev/null 2>&1 || true
if ! systemctl is-active --quiet docker; then
systemctl start docker
fi
}
sync_git_repo() {
local repo_dir=\"\$1\"
@@ -401,86 +477,216 @@ sync_git_repo() {
git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\"
}
publish_static_tree() {
local source_dir=\"\$1\"
local destination_dir=\"\$2\"
get_env_var() {
local key=\"\$1\"
install -d -m 0755 \"\$destination_dir\"
rsync -a --delete \
--include='*/' \
--include='*.html' \
--include='*.css' \
--include='*.js' \
--include='*.mjs' \
--include='*.png' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.svg' \
--include='*.webp' \
--include='*.ico' \
--include='*.pdf' \
--include='*.webmanifest' \
--exclude='*' \
\"\$source_dir/\" \"\$destination_dir/\"
[[ -f \"\$env_file\" ]] || return 0
awk -F= -v wanted=\"\$key\" '\$1 == wanted { print substr(\$0, length(wanted) + 2); exit }' \"\$env_file\"
}
sync_git_repo \"\$main_repo_dir\" '${repo_url}' \"\$main_branch\" 'principal'
set_env_var() {
local key=\"\$1\"
local value=\"\$2\"
touch \"\$env_file\"
if grep -q \"^\${key}=\" \"\$env_file\" 2>/dev/null; then
sed -i \"s|^\${key}=.*|\${key}=\${value}|\" \"\$env_file\"
else
printf '%s=%s\n' \"\$key\" \"\$value\" >> \"\$env_file\"
fi
}
ensure_env_default() {
local key=\"\$1\"
local fallback=\"\$2\"
local value
value=\"\$(get_env_var \"\$key\")\"
if [[ -z \"\$value\" ]]; then
value=\"\$fallback\"
fi
set_env_var \"\$key\" \"\$value\"
}
normalize_public_base_url() {
local value=\"\$1\"
printf '%s\n' \"\${value%/}\"
}
build_default_public_base_url() {
local port=\"\$1\"
local detected_ip
detected_ip=\"\$(hostname -I | awk '{print \$1}')\"
[[ -n \"\$detected_ip\" ]] || return 1
if [[ \"\$port\" == \"80\" ]]; then
printf 'http://%s\n' \"\$detected_ip\"
else
printf 'http://%s:%s\n' \"\$detected_ip\" \"\$port\"
fi
}
configure_env_file() {
local current_value
local effective_web_port
local effective_public_base_url
local effective_keycloak_admin_user
local effective_keycloak_admin_password
effective_web_port=\"\$web_port_override\"
if [[ -z \"\$effective_web_port\" ]]; then
effective_web_port=\"\$(get_env_var WEB_PORT)\"
fi
if [[ -z \"\$effective_web_port\" ]]; then
effective_web_port='80'
fi
set_env_var WEB_PORT \"\$effective_web_port\"
effective_public_base_url=\"\$public_base_url_override\"
if [[ -z \"\$effective_public_base_url\" ]]; then
effective_public_base_url=\"\$(get_env_var PUBLIC_BASE_URL)\"
fi
if [[ -z \"\$effective_public_base_url\" ]]; then
effective_public_base_url=\"\$(build_default_public_base_url \"\$effective_web_port\" || true)\"
fi
if [[ -z \"\$effective_public_base_url\" ]]; then
if [[ \"\$effective_web_port\" == \"80\" ]]; then
effective_public_base_url='http://localhost'
else
effective_public_base_url=\"http://localhost:\$effective_web_port\"
fi
fi
set_env_var PUBLIC_BASE_URL \"\$(normalize_public_base_url \"\$effective_public_base_url\")\"
effective_keycloak_admin_user=\"\$keycloak_admin_user_override\"
if [[ -z \"\$effective_keycloak_admin_user\" ]]; then
effective_keycloak_admin_user=\"\$(get_env_var KEYCLOAK_ADMIN_USER)\"
fi
if [[ -z \"\$effective_keycloak_admin_user\" ]]; then
effective_keycloak_admin_user='admin'
fi
set_env_var KEYCLOAK_ADMIN_USER \"\$effective_keycloak_admin_user\"
effective_keycloak_admin_password=\"\$keycloak_admin_password_override\"
if [[ -z \"\$effective_keycloak_admin_password\" ]]; then
effective_keycloak_admin_password=\"\$(get_env_var KEYCLOAK_ADMIN_PASSWORD)\"
fi
if [[ -z \"\$effective_keycloak_admin_password\" ]]; then
effective_keycloak_admin_password=\"\$(random_secret)\"
fi
set_env_var KEYCLOAK_ADMIN_PASSWORD \"\$effective_keycloak_admin_password\"
ensure_env_default KEYCLOAK_DB_NAME keycloak
ensure_env_default KEYCLOAK_DB_USER keycloak
current_value=\"\$(get_env_var KEYCLOAK_DB_PASSWORD)\"
if [[ -z \"\$current_value\" ]]; then
current_value=\"\$(random_secret)\"
fi
set_env_var KEYCLOAK_DB_PASSWORD \"\$current_value\"
ensure_env_default SITE_DB_NAME chesscubing_site
ensure_env_default SITE_DB_USER chesscubing
current_value=\"\$(get_env_var SITE_DB_PASSWORD)\"
if [[ -z \"\$current_value\" ]]; then
current_value=\"\$(random_secret)\"
fi
set_env_var SITE_DB_PASSWORD \"\$current_value\"
current_value=\"\$(get_env_var SITE_DB_ROOT_PASSWORD)\"
if [[ -z \"\$current_value\" ]]; then
current_value=\"\$(random_secret)\"
fi
set_env_var SITE_DB_ROOT_PASSWORD \"\$current_value\"
}
sync_deploy_tree() {
install -d -m 0755 \"\$deploy_dir\" \"\$config_dir\"
rsync -a --delete \
--exclude='.git/' \
--exclude='bin/' \
--exclude='obj/' \
--exclude='.env' \
--exclude='node_modules/' \
\"\$main_repo_dir/\" \"\$deploy_dir/\"
if [[ -d \"\$ethan_repo_dir/.git\" ]]; then
rm -rf \"\$deploy_dir/ethan\"
rsync -a --delete \
--exclude='.git/' \
--exclude='node_modules/' \
\"\$ethan_repo_dir/\" \"\$deploy_dir/ethan/\"
fi
if [[ -d \"\$brice_repo_dir/.git\" ]]; then
rm -rf \"\$deploy_dir/brice\"
rsync -a --delete \
--exclude='.git/' \
--exclude='node_modules/' \
\"\$brice_repo_dir/\" \"\$deploy_dir/brice/\"
fi
}
disable_legacy_nginx() {
systemctl disable --now nginx >/dev/null 2>&1 || true
}
prepare_disk_space() {
cd \"\$deploy_dir\"
docker builder prune -af || true
docker image prune -f || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* || true
}
ensure_free_space_mb() {
local required_mb=\"\$1\"
local available_mb
available_mb=\"\$(df -Pm / | awk 'NR == 2 { print \$4 }')\"
if [[ -z \"\$available_mb\" ]]; then
echo \"Impossible de mesurer l'espace disque libre dans le LXC.\" >&2
exit 1
fi
if (( available_mb < required_mb )); then
echo \"Espace disque insuffisant dans le LXC: \${available_mb} Mo libres, \${required_mb} Mo recommandes avant la reconstruction Docker.\" >&2
echo \"Agrandis le disque du LXC puis relance la mise a jour.\" >&2
exit 1
fi
}
deploy_stack() {
cp \"\$env_file\" \"\$deploy_dir/.env\"
prepare_disk_space
ensure_free_space_mb 6144
cd \"\$deploy_dir\"
docker compose up -d --build
docker compose ps
}
ensure_docker_stack
sync_git_repo \"\$main_repo_dir\" \"\$main_repo_url\" \"\$main_branch\" 'principal'
sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan'
asset_version=\"\$(git -C \"\$main_repo_dir\" rev-parse --short HEAD)-\$(git -C \"\$ethan_repo_dir\" rev-parse --short HEAD)\"
install -d -m 0755 \"\$web_root\"
publish_static_tree \"\$main_repo_dir\" \"\$web_root\"
publish_static_tree \"\$ethan_repo_dir\" \"\$web_root/ethan\"
while IFS= read -r -d '' html_file; do
LC_ALL=C LANG=C ASSET_VERSION=\"\$asset_version\" perl -0pi -e 's{((?:href|src)=\")(?!https?://|data:|//)([^\"?]+?\.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest))(?:\?[^\"]*)?(\")}{\$1 . \$2 . \"?v=\" . \$ENV{ASSET_VERSION} . \$3}ge' \"\$html_file\"
done < <(find \"\$web_root\" -type f -name '*.html' -print0)
chown -R www-data:www-data \"\$web_root\"
nginx -t
systemctl reload nginx
sync_git_repo \"\$brice_repo_dir\" \"\$brice_repo_url\" \"\$brice_branch\" 'Brice'
sync_deploy_tree
configure_env_file
disable_legacy_nginx
deploy_stack
SCRIPT
chmod +x /usr/local/bin/update-chesscubing"
ct_exec "cat > /etc/nginx/sites-available/chesscubing.conf <<'NGINX'
server {
listen 80;
listen [::]:80;
server_name _;
root /var/www/chesscubing/current;
index index.html;
location = /ethan {
return 301 \$scheme://\$http_host/ethan/;
}
location /ethan/ {
try_files \$uri \$uri/ /ethan/index.html;
}
location / {
try_files \$uri \$uri/ /index.html;
}
location ~* \.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest)$ {
expires -1;
add_header Cache-Control 'no-cache, no-store, must-revalidate';
}
}
NGINX
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/chesscubing.conf /etc/nginx/sites-enabled/chesscubing.conf"
printf 'Publication du site dans le LXC...\n'
ct_exec "/usr/local/bin/update-chesscubing '$repo_branch'"
ct_exec "systemctl enable nginx >/dev/null && systemctl restart nginx"
printf 'Deploiement de la stack Docker complete dans le LXC...\n'
ct_exec "/usr/local/bin/update-chesscubing '$repo_branch' '$public_base_url' '$web_port' '$keycloak_admin_user' '$keycloak_admin_password'"
container_ip="$(pct exec "$ctid" -- bash -lc "hostname -I | awk '{print \$1}'" 2>/dev/null | tr -d '\r' || true)"
public_url="$(pct exec "$ctid" -- bash -lc "awk -F= '/^PUBLIC_BASE_URL=/{print substr(\$0, 17); exit}' /opt/chesscubing/config/chesscubing.env" 2>/dev/null | tr -d '\r' || true)"
effective_keycloak_admin_user="$(pct exec "$ctid" -- bash -lc "awk -F= '/^KEYCLOAK_ADMIN_USER=/{print substr(\$0, 21); exit}' /opt/chesscubing/config/chesscubing.env" 2>/dev/null | tr -d '\r' || true)"
effective_keycloak_admin_password="$(pct exec "$ctid" -- bash -lc "awk -F= '/^KEYCLOAK_ADMIN_PASSWORD=/{print substr(\$0, 25); exit}' /opt/chesscubing/config/chesscubing.env" 2>/dev/null | tr -d '\r' || true)"
cat <<EOF
@@ -488,7 +694,10 @@ Installation terminee.
- CTID: $ctid
- Nom du LXC: $lxc_hostname
- Mot de passe root du LXC: $lxc_password
- URL probable: http://${container_ip:-<ip_du_lxc>}
- IP detectee du LXC: ${container_ip:-<ip_du_lxc>}
- URL publique configuree: ${public_url:-http://${container_ip:-<ip_du_lxc>}}
- Admin Keycloak: ${effective_keycloak_admin_user:-admin}
- Mot de passe admin Keycloak: ${effective_keycloak_admin_password:-<voir /opt/chesscubing/config/chesscubing.env>}
Pour mettre a jour l'application plus tard:
./scripts/update-proxmox-lxc.sh --proxmox-host <ip-proxmox> --proxmox-user <user> --proxmox-password '<motdepasse>' --ctid $ctid
@@ -513,7 +722,13 @@ if [[ "$LOCAL_MODE" == "1" ]]; then
"$REPO_BRANCH" \
"$ETHAN_REPO_URL" \
"$ETHAN_REPO_BRANCH" \
"$LXC_PASSWORD"
"$BRICE_REPO_URL" \
"$BRICE_REPO_BRANCH" \
"$LXC_PASSWORD" \
"$PUBLIC_BASE_URL" \
"$WEB_PORT" \
"$KEYCLOAK_ADMIN_USER" \
"$KEYCLOAK_ADMIN_PASSWORD"
exit 0
fi
@@ -553,4 +768,10 @@ sshpass -p "$PROXMOX_PASSWORD" \
"$REPO_BRANCH" \
"$ETHAN_REPO_URL" \
"$ETHAN_REPO_BRANCH" \
"$LXC_PASSWORD" < "$payload_script"
"$BRICE_REPO_URL" \
"$BRICE_REPO_BRANCH" \
"$LXC_PASSWORD" \
"$PUBLIC_BASE_URL" \
"$WEB_PORT" \
"$KEYCLOAK_ADMIN_USER" \
"$KEYCLOAK_ADMIN_PASSWORD" < "$payload_script"

View File

@@ -22,9 +22,18 @@ Options principales:
--local Execute directement sur l'hote Proxmox local
--ctid CTID du LXC a mettre a jour
--hostname Nom du LXC si le CTID n'est pas fourni (defaut: chesscubing-web)
--disk-gb Taille disque cible du LXC en Go si un agrandissement est necessaire
--repo-url Depot Git principal a deployer
--branch Branche Git a deployer (defaut: main)
--ethan-repo-url Depot Git de l'application Ethan
--ethan-branch Branche Git de l'application Ethan (defaut: main)
--brice-repo-url Depot Git de l'application Brice
--brice-branch Branche Git de l'application Brice (defaut: main)
--public-base-url URL publique du site (ex: http://jeu.example.com)
--web-port Port HTTP expose dans le LXC (defaut: conserve ou 80)
--keycloak-admin-user Utilisateur admin Keycloak a forcer
--keycloak-admin-password
Mot de passe admin Keycloak a forcer
-h, --help Affiche cette aide
EOF
}
@@ -46,9 +55,17 @@ LOCAL_MODE="0"
CTID=""
LXC_HOSTNAME="chesscubing-web"
TARGET_DISK_GB=""
REPO_URL="https://git.jeannerot.fr/christophe/chesscubing.git"
REPO_BRANCH="main"
ETHAN_REPO_URL="https://git.jeannerot.fr/Mineloulou/Chesscubing.git"
ETHAN_REPO_BRANCH="main"
BRICE_REPO_URL="https://git.jeannerot.fr/Lescratcheur/ChessCubing.git"
BRICE_REPO_BRANCH="main"
PUBLIC_BASE_URL=""
WEB_PORT=""
KEYCLOAK_ADMIN_USER=""
KEYCLOAK_ADMIN_PASSWORD=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -80,6 +97,14 @@ while [[ $# -gt 0 ]]; do
LXC_HOSTNAME="${2:-}"
shift 2
;;
--disk-gb)
TARGET_DISK_GB="${2:-}"
shift 2
;;
--repo-url)
REPO_URL="${2:-}"
shift 2
;;
--branch)
REPO_BRANCH="${2:-}"
shift 2
@@ -92,6 +117,30 @@ while [[ $# -gt 0 ]]; do
ETHAN_REPO_BRANCH="${2:-}"
shift 2
;;
--brice-repo-url)
BRICE_REPO_URL="${2:-}"
shift 2
;;
--brice-branch)
BRICE_REPO_BRANCH="${2:-}"
shift 2
;;
--public-base-url)
PUBLIC_BASE_URL="${2:-}"
shift 2
;;
--web-port)
WEB_PORT="${2:-}"
shift 2
;;
--keycloak-admin-user)
KEYCLOAK_ADMIN_USER="${2:-}"
shift 2
;;
--keycloak-admin-password)
KEYCLOAK_ADMIN_PASSWORD="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
@@ -121,15 +170,70 @@ trap 'printf "Erreur: echec de la commande [%s] a la ligne %s.\n" "$BASH_COMMAND
ctid="$1"
lxc_hostname="$2"
repo_branch="$3"
ethan_repo_url="$4"
ethan_repo_branch="$5"
repo_url="$3"
repo_branch="$4"
ethan_repo_url="$5"
ethan_repo_branch="$6"
brice_repo_url="$7"
brice_repo_branch="$8"
public_base_url="$9"
web_port="${10}"
keycloak_admin_user="${11}"
keycloak_admin_password="${12}"
target_disk_gb="${13}"
die() {
printf 'Erreur: %s\n' "$*" >&2
exit 1
}
size_to_mb() {
local raw="${1^^}"
case "$raw" in
*T) echo $(( ${raw%T} * 1024 * 1024 )) ;;
*G) echo $(( ${raw%G} * 1024 )) ;;
*M) echo $(( ${raw%M} )) ;;
*K) echo $(( ${raw%K} / 1024 )) ;;
*) echo "$raw" ;;
esac
}
get_current_rootfs_mb() {
local size
size="$(pct config "$ctid" | awk -F'[:,=]' '/^rootfs:/ { for (i = 1; i <= NF; i++) { if ($i == "size") { print $(i + 1); exit } } }')"
[[ -n "$size" ]] || return 1
size_to_mb "$size"
}
ensure_rootfs_capacity() {
local requested_gb="$1"
local current_mb
local requested_mb
local delta_mb
local delta_gb
local current_gb
[[ -n "$requested_gb" ]] || return 0
[[ "$requested_gb" =~ ^[0-9]+$ ]] || die "La valeur de --disk-gb doit etre un entier en Go."
current_mb="$(get_current_rootfs_mb)" || die "Impossible de lire la taille actuelle du disque rootfs du LXC."
requested_mb=$(( requested_gb * 1024 ))
if (( current_mb >= requested_mb )); then
return 0
fi
delta_mb=$(( requested_mb - current_mb ))
delta_gb=$(( (delta_mb + 1023) / 1024 ))
current_gb=$(( (current_mb + 1023) / 1024 ))
printf 'Agrandissement du disque rootfs du LXC: %sG -> %sG (+%sG).\n' "$current_gb" "$requested_gb" "$delta_gb"
pct resize "$ctid" rootfs "+${delta_gb}G" >/dev/null
}
find_ctid_by_hostname() {
local wanted="$1"
local candidate=""
@@ -164,15 +268,30 @@ if [[ -n "$detected_hostname" ]]; then
lxc_hostname="$detected_hostname"
fi
if ! pct status "$ctid" | grep -q "running"; then
pct start "$ctid"
sleep 5
ensure_rootfs_capacity "$target_disk_gb"
if pct status "$ctid" | grep -q "running"; then
pct stop "$ctid"
fi
pct set "$ctid" --features nesting=1,keyctl=1 >/dev/null
pct start "$ctid"
for _ in $(seq 1 20); do
if pct exec "$ctid" -- true >/dev/null 2>&1; then
break
fi
sleep 2
done
pct exec "$ctid" -- true >/dev/null 2>&1 || die "Le LXC n'est pas joignable apres le redemarrage."
ct_exec() {
pct exec "$ctid" -- bash -lc "$1"
}
ct_exec "install -d -m 0755 /opt/chesscubing/repo /opt/chesscubing/ethan-repo /opt/chesscubing/brice-repo /opt/chesscubing/deploy /opt/chesscubing/config"
ct_exec "cat > /usr/local/bin/update-chesscubing <<'SCRIPT'
#!/usr/bin/env bash
set -Eeuo pipefail
@@ -181,10 +300,56 @@ trap 'printf \"Erreur: echec de la commande [%s] a la ligne %s.\\n\" \"\$BASH_CO
main_repo_dir='/opt/chesscubing/repo'
ethan_repo_dir='/opt/chesscubing/ethan-repo'
web_root='/var/www/chesscubing/current'
brice_repo_dir='/opt/chesscubing/brice-repo'
deploy_dir='/opt/chesscubing/deploy'
config_dir='/opt/chesscubing/config'
env_file=\"\$config_dir/chesscubing.env\"
main_branch=\"\${1:-${repo_branch}}\"
public_base_url_override=\"\${2:-}\"
web_port_override=\"\${3:-}\"
keycloak_admin_user_override=\"\${4:-}\"
keycloak_admin_password_override=\"\${5:-}\"
main_repo_url='${repo_url}'
ethan_repo_url='${ethan_repo_url}'
ethan_branch='${ethan_repo_branch}'
brice_repo_url='${brice_repo_url}'
brice_branch='${brice_repo_branch}'
random_secret() {
od -An -N24 -tx1 /dev/urandom | tr -d ' \n'
}
ensure_base_packages() {
apt-get update
apt-get install -y ca-certificates curl gpg git rsync
}
ensure_docker_stack() {
ensure_base_packages
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
fi
if [[ ! -f /etc/apt/sources.list.d/docker.list ]]; then
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian %s stable\n' \
\"\$(dpkg --print-architecture)\" \
\"\$(. /etc/os-release && printf '%s' \"\$VERSION_CODENAME\")\" > /etc/apt/sources.list.d/docker.list
fi
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
systemctl enable docker >/dev/null 2>&1 || true
if ! systemctl is-active --quiet docker; then
systemctl start docker
fi
}
sync_git_repo() {
local repo_dir=\"\$1\"
@@ -219,91 +384,221 @@ sync_git_repo() {
git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\"
}
publish_static_tree() {
local source_dir=\"\$1\"
local destination_dir=\"\$2\"
get_env_var() {
local key=\"\$1\"
install -d -m 0755 \"\$destination_dir\"
rsync -a --delete \
--include='*/' \
--include='*.html' \
--include='*.css' \
--include='*.js' \
--include='*.mjs' \
--include='*.png' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.svg' \
--include='*.webp' \
--include='*.ico' \
--include='*.pdf' \
--include='*.webmanifest' \
--exclude='*' \
\"\$source_dir/\" \"\$destination_dir/\"
[[ -f \"\$env_file\" ]] || return 0
awk -F= -v wanted=\"\$key\" '\$1 == wanted { print substr(\$0, length(wanted) + 2); exit }' \"\$env_file\"
}
sync_git_repo \"\$main_repo_dir\" '' \"\$main_branch\" 'principal'
set_env_var() {
local key=\"\$1\"
local value=\"\$2\"
touch \"\$env_file\"
if grep -q \"^\${key}=\" \"\$env_file\" 2>/dev/null; then
sed -i \"s|^\${key}=.*|\${key}=\${value}|\" \"\$env_file\"
else
printf '%s=%s\n' \"\$key\" \"\$value\" >> \"\$env_file\"
fi
}
ensure_env_default() {
local key=\"\$1\"
local fallback=\"\$2\"
local value
value=\"\$(get_env_var \"\$key\")\"
if [[ -z \"\$value\" ]]; then
value=\"\$fallback\"
fi
set_env_var \"\$key\" \"\$value\"
}
normalize_public_base_url() {
local value=\"\$1\"
printf '%s\n' \"\${value%/}\"
}
build_default_public_base_url() {
local port=\"\$1\"
local detected_ip
detected_ip=\"\$(hostname -I | awk '{print \$1}')\"
[[ -n \"\$detected_ip\" ]] || return 1
if [[ \"\$port\" == \"80\" ]]; then
printf 'http://%s\n' \"\$detected_ip\"
else
printf 'http://%s:%s\n' \"\$detected_ip\" \"\$port\"
fi
}
configure_env_file() {
local current_value
local effective_web_port
local effective_public_base_url
local effective_keycloak_admin_user
local effective_keycloak_admin_password
effective_web_port=\"\$web_port_override\"
if [[ -z \"\$effective_web_port\" ]]; then
effective_web_port=\"\$(get_env_var WEB_PORT)\"
fi
if [[ -z \"\$effective_web_port\" ]]; then
effective_web_port='80'
fi
set_env_var WEB_PORT \"\$effective_web_port\"
effective_public_base_url=\"\$public_base_url_override\"
if [[ -z \"\$effective_public_base_url\" ]]; then
effective_public_base_url=\"\$(get_env_var PUBLIC_BASE_URL)\"
fi
if [[ -z \"\$effective_public_base_url\" ]]; then
effective_public_base_url=\"\$(build_default_public_base_url \"\$effective_web_port\" || true)\"
fi
if [[ -z \"\$effective_public_base_url\" ]]; then
if [[ \"\$effective_web_port\" == \"80\" ]]; then
effective_public_base_url='http://localhost'
else
effective_public_base_url=\"http://localhost:\$effective_web_port\"
fi
fi
set_env_var PUBLIC_BASE_URL \"\$(normalize_public_base_url \"\$effective_public_base_url\")\"
effective_keycloak_admin_user=\"\$keycloak_admin_user_override\"
if [[ -z \"\$effective_keycloak_admin_user\" ]]; then
effective_keycloak_admin_user=\"\$(get_env_var KEYCLOAK_ADMIN_USER)\"
fi
if [[ -z \"\$effective_keycloak_admin_user\" ]]; then
effective_keycloak_admin_user='admin'
fi
set_env_var KEYCLOAK_ADMIN_USER \"\$effective_keycloak_admin_user\"
effective_keycloak_admin_password=\"\$keycloak_admin_password_override\"
if [[ -z \"\$effective_keycloak_admin_password\" ]]; then
effective_keycloak_admin_password=\"\$(get_env_var KEYCLOAK_ADMIN_PASSWORD)\"
fi
if [[ -z \"\$effective_keycloak_admin_password\" ]]; then
effective_keycloak_admin_password=\"\$(random_secret)\"
fi
set_env_var KEYCLOAK_ADMIN_PASSWORD \"\$effective_keycloak_admin_password\"
ensure_env_default KEYCLOAK_DB_NAME keycloak
ensure_env_default KEYCLOAK_DB_USER keycloak
current_value=\"\$(get_env_var KEYCLOAK_DB_PASSWORD)\"
if [[ -z \"\$current_value\" ]]; then
current_value=\"\$(random_secret)\"
fi
set_env_var KEYCLOAK_DB_PASSWORD \"\$current_value\"
ensure_env_default SITE_DB_NAME chesscubing_site
ensure_env_default SITE_DB_USER chesscubing
current_value=\"\$(get_env_var SITE_DB_PASSWORD)\"
if [[ -z \"\$current_value\" ]]; then
current_value=\"\$(random_secret)\"
fi
set_env_var SITE_DB_PASSWORD \"\$current_value\"
current_value=\"\$(get_env_var SITE_DB_ROOT_PASSWORD)\"
if [[ -z \"\$current_value\" ]]; then
current_value=\"\$(random_secret)\"
fi
set_env_var SITE_DB_ROOT_PASSWORD \"\$current_value\"
}
sync_deploy_tree() {
install -d -m 0755 \"\$deploy_dir\" \"\$config_dir\"
rsync -a --delete \
--exclude='.git/' \
--exclude='bin/' \
--exclude='obj/' \
--exclude='.env' \
--exclude='node_modules/' \
\"\$main_repo_dir/\" \"\$deploy_dir/\"
if [[ -d \"\$ethan_repo_dir/.git\" ]]; then
rm -rf \"\$deploy_dir/ethan\"
rsync -a --delete \
--exclude='.git/' \
--exclude='node_modules/' \
\"\$ethan_repo_dir/\" \"\$deploy_dir/ethan/\"
fi
if [[ -d \"\$brice_repo_dir/.git\" ]]; then
rm -rf \"\$deploy_dir/brice\"
rsync -a --delete \
--exclude='.git/' \
--exclude='node_modules/' \
\"\$brice_repo_dir/\" \"\$deploy_dir/brice/\"
fi
}
disable_legacy_nginx() {
systemctl disable --now nginx >/dev/null 2>&1 || true
}
prepare_disk_space() {
cd \"\$deploy_dir\"
docker builder prune -af || true
docker image prune -f || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* || true
}
ensure_free_space_mb() {
local required_mb=\"\$1\"
local available_mb
available_mb=\"\$(df -Pm / | awk 'NR == 2 { print \$4 }')\"
if [[ -z \"\$available_mb\" ]]; then
echo \"Impossible de mesurer l'espace disque libre dans le LXC.\" >&2
exit 1
fi
if (( available_mb < required_mb )); then
echo \"Espace disque insuffisant dans le LXC: \${available_mb} Mo libres, \${required_mb} Mo recommandes avant la reconstruction Docker.\" >&2
echo \"Relance le script avec --disk-gb 16 ou une valeur plus elevee si besoin.\" >&2
exit 1
fi
}
deploy_stack() {
cp \"\$env_file\" \"\$deploy_dir/.env\"
prepare_disk_space
ensure_free_space_mb 6144
cd \"\$deploy_dir\"
docker compose up -d --build
docker compose ps
}
ensure_docker_stack
sync_git_repo \"\$main_repo_dir\" \"\$main_repo_url\" \"\$main_branch\" 'principal'
sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan'
asset_version=\"\$(git -C \"\$main_repo_dir\" rev-parse --short HEAD)-\$(git -C \"\$ethan_repo_dir\" rev-parse --short HEAD)\"
install -d -m 0755 \"\$web_root\"
publish_static_tree \"\$main_repo_dir\" \"\$web_root\"
publish_static_tree \"\$ethan_repo_dir\" \"\$web_root/ethan\"
while IFS= read -r -d '' html_file; do
LC_ALL=C LANG=C ASSET_VERSION=\"\$asset_version\" perl -0pi -e 's{((?:href|src)=\")(?!https?://|data:|//)([^\"?]+?\.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest))(?:\?[^\"]*)?(\")}{\$1 . \$2 . \"?v=\" . \$ENV{ASSET_VERSION} . \$3}ge' \"\$html_file\"
done < <(find \"\$web_root\" -type f -name '*.html' -print0)
chown -R www-data:www-data \"\$web_root\"
nginx -t
systemctl reload nginx
sync_git_repo \"\$brice_repo_dir\" \"\$brice_repo_url\" \"\$brice_branch\" 'Brice'
sync_deploy_tree
configure_env_file
disable_legacy_nginx
deploy_stack
SCRIPT
chmod +x /usr/local/bin/update-chesscubing"
ct_exec "cat > /etc/nginx/sites-available/chesscubing.conf <<'NGINX'
server {
listen 80;
listen [::]:80;
server_name _;
root /var/www/chesscubing/current;
index index.html;
location = /ethan {
return 301 \$scheme://\$http_host/ethan/;
}
location /ethan/ {
try_files \$uri \$uri/ /ethan/index.html;
}
location / {
try_files \$uri \$uri/ /index.html;
}
location ~* \.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest)$ {
expires -1;
add_header Cache-Control 'no-cache, no-store, must-revalidate';
}
}
NGINX
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/chesscubing.conf /etc/nginx/sites-enabled/chesscubing.conf"
pct exec "$ctid" -- /usr/local/bin/update-chesscubing "$repo_branch"
ct_exec "/usr/local/bin/update-chesscubing '$repo_branch' '$public_base_url' '$web_port' '$keycloak_admin_user' '$keycloak_admin_password'"
container_ip="$(pct exec "$ctid" -- bash -lc "hostname -I | awk '{print \$1}'" 2>/dev/null | tr -d '\r' || true)"
public_url="$(pct exec "$ctid" -- bash -lc "awk -F= '/^PUBLIC_BASE_URL=/{print substr(\$0, 17); exit}' /opt/chesscubing/config/chesscubing.env" 2>/dev/null | tr -d '\r' || true)"
cat <<EOF
Mise a jour terminee.
- CTID: $ctid
- Nom du LXC: $lxc_hostname
- URL probable: http://${container_ip:-<ip_du_lxc>}
- IP detectee du LXC: ${container_ip:-<ip_du_lxc>}
- URL publique configuree: ${public_url:-http://${container_ip:-<ip_du_lxc>}}
EOF
REMOTE
@@ -312,9 +607,17 @@ if [[ "$LOCAL_MODE" == "1" ]]; then
bash "$payload_script" \
"$CTID" \
"$LXC_HOSTNAME" \
"$REPO_URL" \
"$REPO_BRANCH" \
"$ETHAN_REPO_URL" \
"$ETHAN_REPO_BRANCH"
"$ETHAN_REPO_BRANCH" \
"$BRICE_REPO_URL" \
"$BRICE_REPO_BRANCH" \
"$PUBLIC_BASE_URL" \
"$WEB_PORT" \
"$KEYCLOAK_ADMIN_USER" \
"$KEYCLOAK_ADMIN_PASSWORD" \
"$TARGET_DISK_GB"
exit 0
fi
@@ -341,6 +644,14 @@ sshpass -p "$PROXMOX_PASSWORD" \
bash -s -- \
"$CTID" \
"$LXC_HOSTNAME" \
"$REPO_URL" \
"$REPO_BRANCH" \
"$ETHAN_REPO_URL" \
"$ETHAN_REPO_BRANCH" < "$payload_script"
"$ETHAN_REPO_BRANCH" \
"$BRICE_REPO_URL" \
"$BRICE_REPO_BRANCH" \
"$PUBLIC_BASE_URL" \
"$WEB_PORT" \
"$KEYCLOAK_ADMIN_USER" \
"$KEYCLOAK_ADMIN_PASSWORD" \
"$TARGET_DISK_GB" < "$payload_script"

1257
styles.css

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,8 @@ Variables d'environnement reconnues :
CHESSCUBING_GIT_BRANCH
CHESSCUBING_ETHAN_REPO_URL
CHESSCUBING_ETHAN_GIT_BRANCH
CHESSCUBING_BRICE_REPO_URL
CHESSCUBING_BRICE_GIT_BRANCH
EOF
}
@@ -91,6 +93,8 @@ CTID="${CHESSCUBING_CTID:-}"
LXC_HOSTNAME="${CHESSCUBING_LXC_HOSTNAME:-chesscubing-web}"
ETHAN_REPO_URL="${CHESSCUBING_ETHAN_REPO_URL:-https://git.jeannerot.fr/Mineloulou/Chesscubing.git}"
ETHAN_REPO_BRANCH="${CHESSCUBING_ETHAN_GIT_BRANCH:-main}"
BRICE_REPO_URL="${CHESSCUBING_BRICE_REPO_URL:-https://git.jeannerot.fr/Lescratcheur/ChessCubing.git}"
BRICE_REPO_BRANCH="${CHESSCUBING_BRICE_GIT_BRANCH:-main}"
if [[ -z "$LOCAL_MODE" && -z "$PROXMOX_HOST" ]]; then
if have_cmd pct && have_cmd pveam; then
@@ -130,6 +134,8 @@ cmd=(
--branch "$REPO_BRANCH"
--ethan-repo-url "$ETHAN_REPO_URL"
--ethan-branch "$ETHAN_REPO_BRANCH"
--brice-repo-url "$BRICE_REPO_URL"
--brice-branch "$BRICE_REPO_BRANCH"
)
if [[ "$LOCAL_MODE" == "1" ]]; then