commit 87fcbc3e07220dceacc2f72dde9321f113402622 Author: Christophe Date: Sun Apr 12 11:30:49 2026 +0200 CrĂ©e l'application web mobile ChessCubing Arena diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b540d82 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +.codex +WhatsApp Video 2026-04-11 at 20.38.50.mp4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..971a363 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.codex +WhatsApp Video 2026-04-11 at 20.38.50.mp4 diff --git a/ChessCubing_Time_Reglement_Officiel_V1-1.pdf b/ChessCubing_Time_Reglement_Officiel_V1-1.pdf new file mode 100644 index 0000000..f5da68a --- /dev/null +++ b/ChessCubing_Time_Reglement_Officiel_V1-1.pdf @@ -0,0 +1,93 @@ +%PDF-1.4 +%“Ś‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Symbol /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/PageMode /UseNone /Pages 8 0 R /Type /Catalog +>> +endobj +7 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260204130259+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260204130259+00'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +8 0 obj +<< +/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages +>> +endobj +9 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1359 +>> +stream +Gatn&>Bed\&BE]".HWAPDHogm(\b&*UmlFgCq"E]`/?;V-u2J9/nt%6mmmXTY,H=MkC>:mju9'Gr#q0]MhLi)!B;^j_[-?1TtCGi-TRE(0J*fQ%`itXO=%ks('I+[+Kr614`=[I.f0TjI[T0RM:%U2N@Y7i9Mg6da='c)l3AVABDW6'?F+%m/uBs3W-_$&PBnN!],]t:SlUJCd9t!$+FC.\C9pZEk([aDmeLABM+%!bicNkj'p4)oRane06k4Mc3*\u5a)>bC*>+TE`dW?l/+=u,H+/YF2&N8*%2NKQS"CmJFj84p^?$O+7F?.Qs+9*bclY+3K/Mm3GfUg7V7BiQo%)"6L[);`NNcVUfP)h*h0:O?-f^ir\]M>l[rLCCA%AA/K::Q7ZGWm*KJOM>4B]#80!Fb+V>!3AaTp\g4k:blTQH8=bUVFLEm!N@Xa7<_g(Z3Lh!(-@o!endstream +endobj +10 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1031 +>> +stream +Gb!#[9i'e'&A@g>b]-dO&1:1Ep').#.rj4,HL/UFVSss?K0QC(^Yc<6!\/nm)Qd&&W\fb7SnHp(Eu:]p1&kI%!*=5$^L=o5R'2$7P:=^oiHd\5R'=Uib:G*CXCr`J3kLY;\uY6:.Th'9>/$/Jn)%7.;Zb-?d#4)5KQd>_Hg_>RQ,goA1aIP3/-b;9pi21Un^6;kn`.juQ,^>tPlY@L?NOd?f3;_>U*`?=[2N0Q5dK'mWW/]MiKY4PhrM2dMXjJV+5Vs3dSr/oe1lh(@!ArEh(b5pZgE`S+5\/e^C:mriF`*-D4ns[E3c6MFR#du=U\_>*3G/uhklF-4:MknriS+Oi9)-4UK7OYB,[#-s3?VN#!`tn9UFor]RZp*/si\BF!HCUJ9F*#A'clGD5j8**M>n>cl03J+u9Rs'0k3Wik9m]NPU`l#V-\0C2hI\l5eVpd!;>@qG?Ep0`$aWM0=lpH(rjG'=H"nM_9KCS_,kD]$:aBJs36EDZY*E=&GA3s&NAAT@@'<)V)h^877Cfrp@h)*BJs'bt03N8!hdiBr#%_*ld7lZ7Nnqd$00;pU"jl:,X8paH&O5?6jJ7H;BPO,6Fl!O#k@n;XcrEh9e`8Ji%1gnR-d^ggE[bc(e%9B2ekieTY+W%<^GG#0a),^rn%m)q+IM[\RA-FWZ-:*gYsfXB*j>V!*e&=)Pr[[(IbD%?g(cFY_ul.7gu7&/N`n"7@r`0gX3Mg:8_94Wo/C"f+3&@dq]K"Bl;h-sIXkd!(j'q0KiE2+o0fWQ&5Kf.H]l2D9"SmlRT\r,?0_d3Q^9l%X"8bL~>endstream +endobj +xref +0 11 +0000000000 65535 f +0000000073 00000 n +0000000114 00000 n +0000000221 00000 n +0000000298 00000 n +0000000501 00000 n +0000000705 00000 n +0000000773 00000 n +0000001056 00000 n +0000001121 00000 n +0000002571 00000 n +trailer +<< +/ID +[<64150b42f508c76f69b388aa96285995><64150b42f508c76f69b388aa96285995>] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 7 0 R +/Root 6 0 R +/Size 11 +>> +startxref +3694 +%%EOF diff --git a/ChessCubing_Twice_Reglement_Officiel_V2-1.pdf b/ChessCubing_Twice_Reglement_Officiel_V2-1.pdf new file mode 100644 index 0000000..f9a2d5c --- /dev/null +++ b/ChessCubing_Twice_Reglement_Officiel_V2-1.pdf @@ -0,0 +1,124 @@ +%PDF-1.4 +%“Ś‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 7 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +8 0 obj +<< +/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +9 0 obj +<< +/PageMode /UseNone /Pages 11 0 R /Type /Catalog +>> +endobj +10 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260216162615+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260216162615+00'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +11 0 obj +<< +/Count 3 /Kids [ 5 0 R 6 0 R 8 0 R ] /Type /Pages +>> +endobj +12 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1279 +>> +stream +Gat%">Bf'b&:Vs/Qq,TRFY29SZYp-rgRLXZ^!Q-S01*aQ`4+sIDZ>,tA-.T\TK/NP5gml(qW=7b1PkS=_GaJ8f5p`SL&pS(l'PF9W,u7kjaKqX*%uYNss?:GDhGFu$aaq#eijU$,kQo,7X*c6D0eJco0q0%2!=Y)Ln*%GuFIG$6u68;9fhWaVs[??lVp&Um$H>GIGa*qRcN1bk'd%S^kWPT@3X;j@Q[GlYu2phY%(FfSnE=DE.!;4^*0)@iP730N7qYk@W,^b@)<*C1kGJZrdk5f!YgNO!`eQb$M@r.gTZQZ_5%-lQ"j#I:=4!pI#?"`40@)SK%UMADq`P6tK%S5uj\i]3Qp)^R)W;HTul]F#IM?m"AVjs_As*C!r)*3>BYFsAPe29Sn$Kkn'358YOhcShVDPZ:u`MAREaJd$h.,+'g(:d2\&c?ST$Yb?Ftb=4[!Y[0r7SM"`c`t4$Y1[tkliEHI\R7cMc#>keE6$QY6=rY;b/Fe0YeLN%i%I0iB^"$=e.D.aRJEQe2?AJ;jr,Wna@O^SB;]fHjbNpoE.)*H]ShGiY)WMFb#o@][<=),XH#6@ACWNh15)pgU'_-0kAa+Jg/Qe=tiJo6a"4`qL!i+iL,SLg#/d&_;Pd%'go98oK!6HGD=TXLI)RAn[:n96lHZg'Rr_\egdBis4Bqb6#+K,T/"jIY''KPP`hpI!Qe8)EXV.o2*oOB&o:'YEKKAAt6aRd5fccoJEQO(TTTldl>!k&W03Z8?"I,R0Zjj^T/Hn%dPp^^t%5LYWHMgTSXLfLe*T(c89X6Jn*='Z@LTR/V)#GE"lY9'EW2/!sN%_m!j89Wa;TTCkCqEk!Yi%b>=T7)u)HG6<0pR$G8RHa)"nb;/)e7Am%jt?J67KA5p7,$"Ql_-]-%+KjdNj71'=u&*E]GIf"itnKjorH3u]Okd]?>td&Uq9C>@;#['&2^of')Uain]R(bY&C!\;:EI!)k:]NN&gcriR&&'r2hFgLRi8RIV+B1/gH;t(!!HrgibW(:Q_-%f?>l*QOC=u(CsTor;~>endstream +endobj +13 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1179 +>> +stream +Gat%!bAQ&q&DcM"B'EV)W[,@?]B`2YU-Mm=M:tDW%5"hCHRVkO,O"<7L3cs"S[=(N)&DC`c5@r?j-,.?;$$T]_s:S08IG8P8/BSSnlsc/=-$ESCUq_u[9Q1([`Qku&'uWeUYs2*$LYAtTU5f"iA!iQr#_<>ok$Eq-610VR\jE-60dHZp_T)E#0RCV8oA!%rk1&4Gu\(IfFLjT$>O1Gd(W!@oTJmF'=/YcJ)Ht1;B2+R_JnGUiO_(S8'dZP]h<=Vo@bo8JiJ[s.\.)B,BQ)f7M8/Geu*@t-RBIU0^8^oASVi7_bMNcF[LM3jDST`dc]?ur!e(\#juE\7oH3S4o0eIQ2p8a?denRQRJD:BsbHVd3b`Sp$)UTPO&f\i!h:u61Em!+O?hKaG?4K>0uqPEUb0L]5@''L@OYiK1M/W?::j9MYgq/eb]cXZ@0#b_`Pc+a)(X@_L`?5g9H9l_G[U]6tbhHa>!%Og;8:prIUM@hQY^Zj>lg,RjK$qQh;-e'@]kKbGF#k0ta1o[Q%rpC]"]`U)6I5($X/@7V\AR?6=;\NC9UCO%tm8n!j3I#VaZVd=aF9BN_*[af;>dI\)3a%.fNaDJk:[i1qmcXW:[jXe_SAq"gMMq#VAXOPPdN*MO='2IWlh,!WU6$+sZlEr~>endstream +endobj +14 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 784 +>> +stream +Gat=(9lJc?%)(h*npbDo71;\9D8plVB_*6V=`%B@7]+@^%(+$N!^%*dtNr\16!=%/9ZcCh+2d/KQ&Yr#:4imq%rBh70+Q4V]7[ngK%0kLEVOUa9P_;0q:Ccm,$5RfgZMlkAI>#=3Y.^,#&DN+'0*#jR=W),SNH9sNkWo,d'*cUKD%/AshYqB8;TRRV_PRr`W7+Z:rDb119beRPcCls(/g8[C:T8oa[F_Z:.%EJB%T9N$L8_2u;,$ES<5Z8?X$@mCi^#2d'J7GT&fpXBT@b^U<#s0;@^K*>`jJZ]*Jb%_niA%#GNrf'!@5TT:]B6Ob@FGBBM%j0TG._a:%[0_D*9endstream +endobj +xref +0 15 +0000000000 65535 f +0000000073 00000 n +0000000134 00000 n +0000000241 00000 n +0000000353 00000 n +0000000468 00000 n +0000000673 00000 n +0000000878 00000 n +0000000955 00000 n +0000001160 00000 n +0000001229 00000 n +0000001513 00000 n +0000001585 00000 n +0000002956 00000 n +0000004227 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 10 0 R +/Root 9 0 R +/Size 15 +>> +startxref +5102 +%%EOF diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..239d315 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY . /usr/share/nginx/html diff --git a/README.md b/README.md new file mode 100644 index 0000000..75707dd --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# 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`. + +## Ce que fait cette première version + +- configure une rencontre `Twice` ou `Time` +- gère les blocks de 180 secondes et le temps par coup de 20 secondes +- suit les quotas `FAST`, `FREEZE` et `MASTERS` +- orchestre la phase cube avec dĂ©signation du cube, capture des temps et prĂ©paration du block suivant +- 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 + +## Hypothèse de produit + +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. + +## DĂ©marrage avec Docker + +```bash +docker compose down +docker compose up -d --build +``` + +L'application est ensuite disponible sur `http://localhost:8080`. + +## Fichiers clĂ©s + +- `index.html` : structure de l'interface +- `styles.css` : design mobile/tablette +- `app.js` : logique de match et arbitrage +- `docker-compose.yml` + `Dockerfile` : exĂ©cution locale diff --git a/app.js b/app.js new file mode 100644 index 0000000..0c41290 --- /dev/null +++ b/app.js @@ -0,0 +1,1129 @@ +const BLOCK_DURATION_MS = 180000; +const MOVE_LIMIT_MS = 20000; +const TIME_MODE_INITIAL_CLOCK_MS = 600000; +const CUBE_TIME_CAP_MS = 120000; +const STORAGE_KEY = "chesscubing-arena-state-v1"; + +const PRESETS = { + fast: { + label: "FAST", + quota: 6, + description: "Format nerveux : 6 coups par joueur et par block.", + }, + freeze: { + label: "FREEZE", + quota: 8, + description: "Format intermĂ©diaire : 8 coups par joueur et par block.", + }, + masters: { + label: "MASTERS", + quota: 10, + description: "Format long : 10 coups par joueur et par block.", + }, +}; + +const MODES = { + twice: { + label: "ChessCubing Twice", + subtitle: + "Le gagnant du cube dĂ©marre le block suivant. Double coup V2 possible.", + }, + time: { + label: "ChessCubing Time", + subtitle: + "Blocks identiques au Twice avec chronos cumulĂ©s et alternance bloc - / +.", + }, +}; + +const refs = { + setupForm: document.querySelector("#setupForm"), + setupSummary: document.querySelector("#setupSummary"), + loadDemoButton: document.querySelector("#loadDemoButton"), + livePanel: document.querySelector("#livePanel"), + heroModeHint: document.querySelector("#heroModeHint"), + matchTitle: document.querySelector("#matchTitle"), + phaseBadge: document.querySelector("#phaseBadge"), + blockLabel: document.querySelector("#blockLabel"), + modeLabel: document.querySelector("#modeLabel"), + blockMeta: document.querySelector("#blockMeta"), + blockTimer: document.querySelector("#blockTimer"), + moveTimer: document.querySelector("#moveTimer"), + blockStatusText: document.querySelector("#blockStatusText"), + turnLabel: document.querySelector("#turnLabel"), + whiteNameDisplay: document.querySelector("#whiteNameDisplay"), + blackNameDisplay: document.querySelector("#blackNameDisplay"), + whiteMoveCount: document.querySelector("#whiteMoveCount"), + blackMoveCount: document.querySelector("#blackMoveCount"), + whiteClockLabel: document.querySelector("#whiteClockLabel"), + blackClockLabel: document.querySelector("#blackClockLabel"), + whiteCubeLabel: document.querySelector("#whiteCubeLabel"), + blackCubeLabel: document.querySelector("#blackCubeLabel"), + whiteCard: document.querySelector("#whiteCard"), + blackCard: document.querySelector("#blackCard"), + startPauseButton: document.querySelector("#startPauseButton"), + confirmBlockButton: document.querySelector("#confirmBlockButton"), + moveActionButton: document.querySelector("#moveActionButton"), + reliefMoveButton: document.querySelector("#reliefMoveButton"), + timeoutMoveButton: document.querySelector("#timeoutMoveButton"), + switchTurnButton: document.querySelector("#switchTurnButton"), + contextNotice: document.querySelector("#contextNotice"), + doubleCard: document.querySelector("#doubleCard"), + cubeNumber: document.querySelector("#cubeNumber"), + startCubeButton: document.querySelector("#startCubeButton"), + cubeElapsed: document.querySelector("#cubeElapsed"), + cubeStatusText: document.querySelector("#cubeStatusText"), + captureWhiteCubeButton: document.querySelector("#captureWhiteCubeButton"), + captureBlackCubeButton: document.querySelector("#captureBlackCubeButton"), + whiteCubeResult: document.querySelector("#whiteCubeResult"), + blackCubeResult: document.querySelector("#blackCubeResult"), + whiteCubeCap: document.querySelector("#whiteCubeCap"), + blackCubeCap: document.querySelector("#blackCubeCap"), + applyCubeButton: document.querySelector("#applyCubeButton"), + redoCubeButton: document.querySelector("#redoCubeButton"), + historyList: document.querySelector("#historyList"), + resetMatchButton: document.querySelector("#resetMatchButton"), + whiteWinButton: document.querySelector("#whiteWinButton"), + blackWinButton: document.querySelector("#blackWinButton"), + drawStopButton: document.querySelector("#drawStopButton"), +}; + +const state = { + match: restoreState(), + lastTickAt: null, +}; + +hydrateRecoveredState(); +bindEvents(); +renderSetupSummary(); +render(); +startTicker(); + +function bindEvents() { + document.querySelectorAll("[data-scroll-target]").forEach((button) => { + button.addEventListener("click", () => { + const target = document.getElementById(button.dataset.scrollTarget); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }); + + refs.setupForm.addEventListener("submit", (event) => { + event.preventDefault(); + const formData = new FormData(refs.setupForm); + const config = { + matchLabel: sanitizeText(formData.get("matchLabel")) || "Rencontre ChessCubing", + mode: formData.get("mode") || "twice", + preset: formData.get("preset") || "fast", + whiteName: sanitizeText(formData.get("whiteName")) || "Blanc", + blackName: sanitizeText(formData.get("blackName")) || "Noir", + arbiterName: sanitizeText(formData.get("arbiterName")) || "", + eventName: sanitizeText(formData.get("eventName")) || "", + notes: sanitizeText(formData.get("notes")) || "", + }; + + state.match = createMatch(config); + persistState(); + refs.livePanel.classList.remove("hidden"); + refs.livePanel.scrollIntoView({ behavior: "smooth", block: "start" }); + render(); + }); + + refs.setupForm.addEventListener("input", renderSetupSummary); + refs.loadDemoButton.addEventListener("click", loadDemo); + + refs.startPauseButton.addEventListener("click", () => { + if (!state.match || state.match.result) { + return; + } + + if (state.match.phase === "block" && state.match.running) { + pauseBlock(); + } else { + startBlock(); + } + }); + + refs.confirmBlockButton.addEventListener("click", () => { + if (!state.match || state.match.result) { + return; + } + + if (state.match.phase === "cube") { + return; + } + + syncRunningTimers(); + + if (!state.match.awaitingBlockClosure && state.match.phase === "block") { + requestBlockClosure("ClĂ´ture manuelle du block demandĂ©e par l'arbitre."); + } else { + openCubePhase(); + } + }); + + refs.moveActionButton.addEventListener("click", () => { + if (!state.match || state.match.phase !== "block" || !state.match.running) { + return; + } + + if (state.match.doubleCoup.step === 1) { + registerFreeDoubleMove(); + return; + } + + const isSecondDoubleMove = state.match.doubleCoup.step === 2; + registerCountedMove({ + label: isSecondDoubleMove + ? "Deuxième coup du double coup enregistrĂ©." + : "Coup comptĂ© enregistrĂ©.", + source: isSecondDoubleMove ? "double" : "standard", + }); + }); + + refs.reliefMoveButton.addEventListener("click", () => { + if (!state.match || state.match.phase !== "block") { + return; + } + registerReliefMove(); + }); + + refs.timeoutMoveButton.addEventListener("click", () => { + if (!state.match || state.match.phase !== "block") { + return; + } + registerMoveTimeout(false); + }); + + refs.switchTurnButton.addEventListener("click", () => { + if (!state.match || state.match.result) { + return; + } + state.match.currentTurn = opponentOf(state.match.currentTurn); + state.match.moveRemainingMs = MOVE_LIMIT_MS; + logEvent("Trait corrigĂ© manuellement par l'arbitre."); + persistState(); + render(); + }); + + refs.startCubeButton.addEventListener("click", startCubePhase); + refs.captureWhiteCubeButton.addEventListener("click", () => captureCubeTime("white")); + refs.captureBlackCubeButton.addEventListener("click", () => captureCubeTime("black")); + refs.applyCubeButton.addEventListener("click", applyCubeOutcome); + refs.redoCubeButton.addEventListener("click", replayCubePhase); + + refs.resetMatchButton.addEventListener("click", resetMatch); + refs.whiteWinButton.addEventListener("click", () => setResult("white")); + refs.blackWinButton.addEventListener("click", () => setResult("black")); + refs.drawStopButton.addEventListener("click", () => setResult("stopped")); +} + +function createMatch(config) { + const quota = PRESETS[config.preset].quota; + const match = { + config, + phase: "block", + running: false, + blockNumber: 1, + currentTurn: "white", + blockRemainingMs: BLOCK_DURATION_MS, + moveRemainingMs: MOVE_LIMIT_MS, + quota, + moves: { + white: 0, + black: 0, + }, + clocks: config.mode === "time" + ? { + white: TIME_MODE_INITIAL_CLOCK_MS, + black: TIME_MODE_INITIAL_CLOCK_MS, + } + : null, + lastMover: null, + awaitingBlockClosure: false, + closureReason: "", + result: null, + cube: { + number: null, + running: false, + startedAt: null, + elapsedMs: 0, + times: { + white: null, + black: null, + }, + round: 1, + history: [], + }, + doubleCoup: { + eligible: false, + step: 0, + starter: "white", + }, + history: [], + }; + + logEvent( + `Match créé en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`, + match, + ); + logEvent("Les Blancs commencent le block 1.", match); + + return match; +} + +function restoreState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function hydrateRecoveredState() { + if (!state.match) { + return; + } + + if (state.match.running) { + state.match.running = false; + state.match.awaitingBlockClosure = false; + state.match.closureReason = ""; + state.match.moveRemainingMs = Math.max(state.match.moveRemainingMs, 0); + } + + if (state.match.cube?.running) { + state.match.cube.running = false; + state.match.cube.startedAt = null; + } +} + +function renderSetupSummary() { + const formData = new FormData(refs.setupForm); + const mode = formData.get("mode") || "twice"; + const preset = formData.get("preset") || "fast"; + const quota = PRESETS[preset].quota; + + refs.setupSummary.innerHTML = ` + ${MODES[mode].label} + ${PRESETS[preset].description} + Chaque block dure 180 secondes et chaque coup est limitĂ© Ă  20 secondes. + ${ + mode === "time" + ? "Chronos cumulĂ©s de 10 minutes par joueur, ajustĂ©s par les temps cube avec plafond de 120 s pris en compte." + : "Le gagnant du cube ouvre le block suivant, avec double coup Ă©ventuel selon la règle V2." + } + Quota actif : ${quota} coups par joueur et par block. + `; + + refs.heroModeHint.textContent = MODES[mode].subtitle; +} + +function startTicker() { + window.setInterval(() => { + if (!state.match || state.match.result) { + return; + } + + syncRunningTimers(); + render(); + }, 100); +} + +function startBlock() { + if (!state.match || state.match.result) { + return; + } + + if (state.match.phase !== "block") { + return; + } + + state.match.running = true; + state.match.awaitingBlockClosure = false; + state.match.closureReason = ""; + state.lastTickAt = Date.now(); + + const intro = + state.match.blockNumber === 1 && state.match.moves.white === 0 && state.match.moves.black === 0 + ? "Block 1 dĂ©marrĂ©." + : `Block ${state.match.blockNumber} relancĂ©.`; + + logEvent(intro); + persistState(); + render(); +} + +function pauseBlock() { + if (!state.match || !state.match.running) { + return; + } + + syncRunningTimers(); + state.match.running = false; + logEvent(`Block ${state.match.blockNumber} mis en pause.`); + persistState(); + render(); +} + +function syncRunningTimers() { + if (!state.match || !state.match.running || state.match.phase !== "block") { + state.lastTickAt = Date.now(); + return; + } + + const now = Date.now(); + const delta = now - (state.lastTickAt || now); + state.lastTickAt = now; + + if (delta <= 0) { + return; + } + + state.match.blockRemainingMs = Math.max(0, state.match.blockRemainingMs - delta); + state.match.moveRemainingMs = Math.max(0, state.match.moveRemainingMs - delta); + + if (state.match.clocks) { + state.match.clocks[state.match.currentTurn] -= delta; + } + + if (state.match.blockRemainingMs === 0) { + requestBlockClosure("Les 180 secondes du block sont Ă©coulĂ©es."); + return; + } + + if (state.match.moveRemainingMs === 0) { + registerMoveTimeout(true); + } +} + +function requestBlockClosure(reason) { + if (!state.match || state.match.awaitingBlockClosure) { + return; + } + + state.match.running = false; + state.match.awaitingBlockClosure = true; + state.match.closureReason = reason; + state.match.moveRemainingMs = MOVE_LIMIT_MS; + logEvent(`${reason} VĂ©rifier un Ă©ventuel Ă©chec Ă  parer avant la phase cube.`); + persistState(); + render(); +} + +function openCubePhase() { + if (!state.match) { + return; + } + + state.match.phase = "cube"; + state.match.running = false; + state.match.awaitingBlockClosure = false; + state.match.closureReason = ""; + state.match.cube.number = pickCubeNumber(); + state.match.cube.running = false; + state.match.cube.startedAt = null; + state.match.cube.elapsedMs = 0; + state.match.cube.times = { white: null, black: null }; + state.match.cube.round = 1; + logEvent(`Phase cube ouverte. Cube n°${state.match.cube.number} dĂ©signĂ© par l'application.`); + persistState(); + render(); +} + +function startCubePhase() { + if (!state.match || state.match.phase !== "cube" || state.match.result) { + return; + } + + if (!state.match.cube.number) { + state.match.cube.number = pickCubeNumber(); + } + + state.match.cube.running = true; + state.match.cube.startedAt = Date.now(); + state.match.cube.elapsedMs = 0; + logEvent(`Phase cube dĂ©marrĂ©e sur le cube n°${state.match.cube.number}.`); + persistState(); + render(); +} + +function captureCubeTime(color) { + if (!state.match || state.match.phase !== "cube" || !state.match.cube.running) { + return; + } + + if (state.match.cube.times[color] !== null) { + return; + } + + const elapsedMs = Date.now() - state.match.cube.startedAt; + state.match.cube.times[color] = elapsedMs; + state.match.cube.elapsedMs = Math.max(state.match.cube.elapsedMs, elapsedMs); + logEvent(`${playerLabel(color)} arrĂŞtĂ© en ${formatStopwatch(elapsedMs)} sur la phase cube.`); + + if ( + state.match.cube.times.white !== null && + state.match.cube.times.black !== null + ) { + state.match.cube.running = false; + state.match.cube.history.push({ + blockNumber: state.match.blockNumber, + number: state.match.cube.number, + white: state.match.cube.times.white, + black: state.match.cube.times.black, + }); + } + + persistState(); + render(); +} + +function applyCubeOutcome() { + if (!state.match || state.match.phase !== "cube") { + return; + } + + const { white, black } = state.match.cube.times; + + if (white === null || black === null) { + return; + } + + if (state.match.config.mode === "twice") { + if (white === black) { + logEvent("ÉgalitĂ© parfaite sur la phase cube : le règlement impose de rejouer la phase."); + render(); + return; + } + + const winner = white < black ? "white" : "black"; + prepareNextTwiceBlock(winner); + } else { + applyTimeAdjustments(white, black); + prepareNextTimeBlock(); + } + + persistState(); + render(); +} + +function prepareNextTwiceBlock(winner) { + if (!state.match) { + return; + } + + const hadDouble = state.match.lastMover !== winner && state.match.lastMover !== null; + logEvent(`${playerLabel(winner)} gagne la phase cube et commencera le block suivant.`); + + state.match.blockNumber += 1; + state.match.phase = "block"; + state.match.running = false; + state.match.blockRemainingMs = BLOCK_DURATION_MS; + state.match.moveRemainingMs = MOVE_LIMIT_MS; + state.match.moves = { white: 0, black: 0 }; + state.match.currentTurn = winner; + state.match.doubleCoup = { + eligible: hadDouble, + step: hadDouble ? 1 : 0, + starter: winner, + }; + state.match.cube.running = false; + state.match.cube.startedAt = null; + state.match.cube.elapsedMs = 0; + state.match.cube.times = { white: null, black: null }; + state.match.cube.number = null; + + if (hadDouble) { + logEvent( + `Double coup disponible pour ${playerLabel( + winner, + )} : premier coup gratuit sans Ă©chec, puis second coup comptĂ©.`, + ); + } else { + logEvent("Aucun double coup accordĂ© sur ce dĂ©part."); + } +} + +function prepareNextTimeBlock() { + if (!state.match) { + return; + } + + state.match.blockNumber += 1; + state.match.phase = "block"; + state.match.running = false; + state.match.blockRemainingMs = BLOCK_DURATION_MS; + state.match.moveRemainingMs = MOVE_LIMIT_MS; + state.match.moves = { white: 0, black: 0 }; + state.match.doubleCoup = { + eligible: false, + step: 0, + starter: state.match.currentTurn, + }; + state.match.cube.running = false; + state.match.cube.startedAt = null; + state.match.cube.elapsedMs = 0; + state.match.cube.times = { white: null, black: null }; + state.match.cube.number = null; + logEvent( + `Block ${state.match.blockNumber} prĂŞt. Le trait est conservĂ© en mode Time : ${playerLabel( + state.match.currentTurn, + )} reprend.`, + ); +} + +function applyTimeAdjustments(whiteTime, blackTime) { + if (!state.match || !state.match.clocks) { + return; + } + + const blockType = getTimeBlockType(state.match.blockNumber); + const cappedWhite = Math.min(whiteTime, CUBE_TIME_CAP_MS); + const cappedBlack = Math.min(blackTime, CUBE_TIME_CAP_MS); + + if (blockType === "minus") { + state.match.clocks.white -= cappedWhite; + state.match.clocks.black -= cappedBlack; + logEvent( + `Bloc - : ${formatStopwatch(cappedWhite)} retirĂ© au chrono Blanc, ${formatStopwatch( + cappedBlack, + )} retirĂ© au chrono Noir.`, + ); + } else { + state.match.clocks.white += cappedBlack; + state.match.clocks.black += cappedWhite; + logEvent( + `Bloc + : ${formatStopwatch(cappedBlack)} ajoutĂ© au chrono Blanc, ${formatStopwatch( + cappedWhite, + )} ajoutĂ© au chrono Noir.`, + ); + } +} + +function replayCubePhase() { + if (!state.match || state.match.phase !== "cube") { + return; + } + + state.match.cube.running = false; + state.match.cube.startedAt = null; + state.match.cube.elapsedMs = 0; + state.match.cube.times = { white: null, black: null }; + state.match.cube.round += 1; + logEvent(`Phase cube relancĂ©e (tentative ${state.match.cube.round}).`); + persistState(); + render(); +} + +function registerCountedMove({ label, source }) { + if (!state.match || state.match.phase !== "block") { + return; + } + + const actor = state.match.currentTurn; + if (state.match.moves[actor] >= state.match.quota) { + logEvent( + `${playerLabel(actor)} a dĂ©jĂ  atteint son quota de coups pour ce block. Utiliser un coup hors quota si nĂ©cessaire.`, + ); + render(); + return; + } + + state.match.moves[actor] += 1; + state.match.lastMover = actor; + state.match.moveRemainingMs = MOVE_LIMIT_MS; + + if (source === "double") { + state.match.doubleCoup.step = 0; + } + + logEvent( + `${label} ${playerLabel(actor)} est Ă  ${state.match.moves[actor]} / ${state.match.quota}.`, + ); + + state.match.currentTurn = opponentOf(actor); + verifyQuotaCompletion(); + persistState(); + render(); +} + +function registerFreeDoubleMove() { + if (!state.match || state.match.doubleCoup.step !== 1) { + return; + } + + const actor = state.match.currentTurn; + state.match.lastMover = actor; + state.match.moveRemainingMs = MOVE_LIMIT_MS; + state.match.doubleCoup.step = 2; + logEvent( + `Premier coup gratuit du double coup jouĂ© par ${playerLabel( + actor, + )}. Rappel arbitre : il ne doit pas donner Ă©chec.`, + ); + persistState(); + render(); +} + +function registerReliefMove() { + if (!state.match) { + return; + } + + const actor = state.match.currentTurn; + state.match.lastMover = actor; + state.match.currentTurn = opponentOf(actor); + state.match.moveRemainingMs = MOVE_LIMIT_MS; + logEvent( + `Coup hors quota jouĂ© par ${playerLabel( + actor, + )} pour gĂ©rer une situation arbitrale ou parer un Ă©chec.`, + ); + persistState(); + render(); +} + +function registerMoveTimeout(fromTimer) { + if (!state.match) { + return; + } + + const actor = state.match.currentTurn; + + if (state.match.doubleCoup.step === 1) { + state.match.doubleCoup.step = 0; + state.match.moveRemainingMs = MOVE_LIMIT_MS; + logEvent( + `DĂ©passement sur le premier coup gratuit de ${playerLabel( + actor, + )} : le double coup est annulĂ©, mais le joueur conserve son coup normal.`, + ); + persistState(); + render(); + return; + } + + if (state.match.moves[actor] < state.match.quota) { + state.match.moves[actor] += 1; + } + + if (state.match.doubleCoup.step === 2) { + state.match.doubleCoup.step = 0; + } + + state.match.currentTurn = opponentOf(actor); + state.match.moveRemainingMs = MOVE_LIMIT_MS; + logEvent( + `${fromTimer ? "Temps par coup Ă©coulĂ©." : "DĂ©passement manuel 20 s."} ${playerLabel( + actor, + )} perd son coup, qui est comptabilisĂ© dans le quota.`, + ); + verifyQuotaCompletion(); + persistState(); + render(); +} + +function verifyQuotaCompletion() { + if (!state.match) { + return; + } + + if ( + state.match.moves.white >= state.match.quota && + state.match.moves.black >= state.match.quota + ) { + requestBlockClosure("Les deux joueurs ont atteint leur quota de coups."); + } +} + +function setResult(winner) { + if (!state.match || state.match.result) { + return; + } + + syncRunningTimers(); + state.match.running = false; + state.match.result = winner; + + if (winner === "white" || winner === "black") { + logEvent(`${playerLabel(winner)} remporte la partie par dĂ©cision arbitrale, mat ou abandon.`); + } else { + logEvent("La partie est arrĂŞtĂ©e par abandon ou dĂ©cision arbitrale."); + } + + persistState(); + render(); +} + +function resetMatch() { + localStorage.removeItem(STORAGE_KEY); + state.match = null; + refs.livePanel.classList.add("hidden"); + render(); +} + +function pickCubeNumber() { + return Math.floor(Math.random() * 4) + 1; +} + +function render() { + const match = state.match; + refs.livePanel.classList.toggle("hidden", !match); + + if (!match) { + return; + } + + refs.matchTitle.textContent = match.config.matchLabel; + refs.modeLabel.textContent = MODES[match.config.mode].label; + refs.phaseBadge.textContent = renderPhaseBadge(match); + refs.blockLabel.textContent = `Block ${match.blockNumber}`; + refs.blockMeta.textContent = renderBlockMeta(match); + refs.blockTimer.textContent = formatClock(match.blockRemainingMs); + refs.moveTimer.textContent = formatClock(match.moveRemainingMs); + refs.blockStatusText.textContent = renderBlockStatus(match); + refs.turnLabel.textContent = `Trait : ${playerLabel(match.currentTurn)}`; + + refs.whiteNameDisplay.textContent = match.config.whiteName; + refs.blackNameDisplay.textContent = match.config.blackName; + refs.whiteMoveCount.textContent = `${match.moves.white} / ${match.quota} coups`; + refs.blackMoveCount.textContent = `${match.moves.black} / ${match.quota} coups`; + + refs.whiteClockLabel.textContent = renderClockLabel(match, "white"); + refs.blackClockLabel.textContent = renderClockLabel(match, "black"); + refs.whiteCubeLabel.textContent = renderLastCubeLabel(match, "white"); + refs.blackCubeLabel.textContent = renderLastCubeLabel(match, "black"); + + refs.whiteCard.classList.toggle("active", match.currentTurn === "white" && !match.result); + refs.blackCard.classList.toggle("active", match.currentTurn === "black" && !match.result); + + refs.startPauseButton.textContent = + match.phase === "block" && match.running ? "Mettre en pause" : "DĂ©marrer le block"; + refs.startPauseButton.disabled = match.phase !== "block" || Boolean(match.result); + refs.confirmBlockButton.disabled = match.phase !== "block" || Boolean(match.result); + refs.confirmBlockButton.textContent = match.awaitingBlockClosure + ? "Ouvrir la phase cube" + : "Clore le block"; + + refs.moveActionButton.disabled = + match.phase !== "block" || !match.running || Boolean(match.result); + refs.moveActionButton.textContent = renderMoveButtonLabel(match); + + refs.reliefMoveButton.disabled = match.phase !== "block" || Boolean(match.result); + refs.timeoutMoveButton.disabled = match.phase !== "block" || Boolean(match.result); + refs.switchTurnButton.disabled = Boolean(match.result); + + refs.contextNotice.innerHTML = `Contexte du moment
${renderContextNotice( + match, + )}`; + refs.doubleCard.innerHTML = renderDoubleCard(match); + + refs.cubeNumber.textContent = match.cube.number || "-"; + refs.cubeElapsed.textContent = renderCubeElapsed(match); + refs.cubeStatusText.textContent = renderCubeStatus(match); + refs.startCubeButton.disabled = + match.phase !== "cube" || match.cube.running || Boolean(match.result); + refs.captureWhiteCubeButton.disabled = + match.phase !== "cube" || !match.cube.running || match.cube.times.white !== null; + refs.captureBlackCubeButton.disabled = + match.phase !== "cube" || !match.cube.running || match.cube.times.black !== null; + refs.applyCubeButton.disabled = + match.phase !== "cube" || + match.cube.times.white === null || + match.cube.times.black === null || + Boolean(match.result); + refs.redoCubeButton.disabled = match.phase !== "cube" || Boolean(match.result); + + refs.whiteCubeResult.textContent = + match.cube.times.white === null ? "--" : formatStopwatch(match.cube.times.white); + refs.blackCubeResult.textContent = + match.cube.times.black === null ? "--" : formatStopwatch(match.cube.times.black); + refs.whiteCubeCap.textContent = renderCubeCap(match.cube.times.white); + refs.blackCubeCap.textContent = renderCubeCap(match.cube.times.black); + + refs.historyList.innerHTML = [...match.history] + .slice(-18) + .reverse() + .map( + (item) => ` +
  • + ${escapeHtml(item.time)} + ${escapeHtml(item.message)} +
  • + `, + ) + .join(""); +} + +function renderPhaseBadge(match) { + if (match.result) { + return "Partie terminĂ©e"; + } + + if (match.phase === "cube") { + return "Phase cube"; + } + + if (match.awaitingBlockClosure) { + return "Fin de block Ă  confirmer"; + } + + return match.running ? "Block en cours" : "Block prĂŞt"; +} + +function renderBlockMeta(match) { + const preset = PRESETS[match.config.preset]; + if (match.config.mode === "time") { + const type = getTimeBlockType(match.blockNumber) === "minus" ? "Bloc -" : "Bloc +"; + return `${type} • ${preset.label} • ${preset.quota} coups par joueur • Le trait est conservĂ© après le cube.`; + } + + return `${preset.label} • ${preset.quota} coups par joueur • Le gagnant du cube dĂ©marrera le block suivant.`; +} + +function renderBlockStatus(match) { + if (match.result) { + return "La rencontre est archivĂ©e dans l'historique."; + } + + if (match.phase === "cube") { + return "Le jeu d'Ă©checs est suspendu, aucun coup n'est autorisĂ©."; + } + + if (match.awaitingBlockClosure) { + return match.closureReason; + } + + return match.running + ? "Le block tourne. Utiliser les commandes d'arbitrage Ă  chaque coup." + : "Le block est prĂŞt, les chronos sont arrĂŞtĂ©s."; +} + +function renderClockLabel(match, color) { + if (!match.clocks) { + return "Mode Twice : pas de chrono cumulĂ©."; + } + + return `Chrono cumulĂ© : ${formatSignedClock(match.clocks[color])}`; +} + +function renderLastCubeLabel(match, color) { + const last = match.cube.history.at(-1); + if (!last) { + return "Dernière phase cube : --"; + } + + return `Dernière phase cube : ${formatStopwatch(last[color])}`; +} + +function renderMoveButtonLabel(match) { + if (match.doubleCoup.step === 1) { + return "1er coup gratuit du double"; + } + + if (match.doubleCoup.step === 2) { + return "2e coup du double"; + } + + return "Coup comptĂ©"; +} + +function renderContextNotice(match) { + if (match.result) { + return "Le match est terminĂ©. L'historique conserve les grandes Ă©tapes pour une reprise ultĂ©rieure."; + } + + if (match.phase === "cube") { + if (match.config.mode === "twice") { + return "Le gagnant du cube dĂ©terminera le dĂ©part du block suivant. En cas d'Ă©galitĂ© parfaite, la phase doit ĂŞtre rejouĂ©e."; + } + + const type = getTimeBlockType(match.blockNumber) === "minus" ? "Bloc -" : "Bloc +"; + return `${type} : le temps cube sera ${ + type === "Bloc -" + ? "retirĂ© du chrono du joueur concernĂ©" + : "ajoutĂ© au chrono adverse" + }, avec plafond pris en compte de 120 secondes.`; + } + + if (match.awaitingBlockClosure) { + return "Le block est thĂ©oriquement terminĂ©. Si un roi est en Ă©chec, jouer les coups nĂ©cessaires hors quota, puis ouvrir la phase cube."; + } + + if (match.config.mode === "time") { + return "Mode Time : la partie ne se termine jamais au temps. Les chronos cumulĂ©s servent de ressource stratĂ©gique, mais seule la victoire par mat ou abandon compte."; + } + + return "Mode Twice : surveiller la fin du block, la victoire en cube, et la règle du double coup V2 pour le dĂ©part suivant."; +} + +function renderDoubleCard(match) { + if (match.config.mode !== "twice") { + return "Mode TimeAucun système de prioritĂ© ou de double coup n'existe dans cette variante."; + } + + if (!match.doubleCoup.eligible && match.doubleCoup.step === 0) { + return "Double coupPas de double coup actif pour ce dĂ©part."; + } + + if (match.doubleCoup.step === 1) { + return `Double coup actif pour ${escapeHtml( + playerLabel(match.doubleCoup.starter), + )}Premier coup gratuit, non comptĂ©, sans Ă©chec autorisĂ©. Utiliser le bouton principal pour l'enregistrer.`; + } + + if (match.doubleCoup.step === 2) { + return `Deuxième coup du doubleCe coup compte comme premier coup du block. Capture autorisĂ©e uniquement sur pion ou pièce mineure. L'Ă©chec redevient autorisĂ©.`; + } + + return "Double coupLe dĂ©part est standard sur ce block."; +} + +function renderCubeElapsed(match) { + if (match.phase !== "cube") { + return "00:00.0"; + } + + if (match.cube.running) { + const live = Date.now() - match.cube.startedAt; + return formatStopwatch(live); + } + + if (match.cube.elapsedMs > 0) { + return formatStopwatch(match.cube.elapsedMs); + } + + return "00:00.0"; +} + +function renderCubeStatus(match) { + if (match.phase !== "cube") { + return "La phase cube se dĂ©clenche Ă  la fin du block."; + } + + if (match.cube.running) { + return "Les deux joueurs sont en rĂ©solution. ArrĂŞter chaque cĂ´tĂ© au moment de la fin."; + } + + if (match.cube.times.white !== null && match.cube.times.black !== null) { + if (match.config.mode === "twice" && match.cube.times.white === match.cube.times.black) { + return "ÉgalitĂ© parfaite : le règlement impose de rejouer immĂ©diatement la phase cube."; + } + + return "Les deux temps sont saisis. Appliquer l'issue pour prĂ©parer le block suivant."; + } + + return "Lancer la phase cube puis capturer chaque fin de rĂ©solution."; +} + +function renderCubeCap(time) { + if (time === null) { + return ""; + } + + if (!state.match || state.match.config.mode !== "time") { + return ""; + } + + if (time <= CUBE_TIME_CAP_MS) { + return "plafond non atteint"; + } + + return `pris en compte : ${formatStopwatch(CUBE_TIME_CAP_MS)}`; +} + +function formatClock(ms) { + const totalSeconds = Math.floor(ms / 1000); + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); + const seconds = String(totalSeconds % 60).padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +function formatSignedClock(ms) { + const negative = ms < 0; + const absolute = Math.abs(ms); + const totalSeconds = Math.floor(absolute / 1000); + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); + const seconds = String(totalSeconds % 60).padStart(2, "0"); + return `${negative ? "-" : ""}${minutes}:${seconds}`; +} + +function formatStopwatch(ms) { + const totalSeconds = Math.floor(ms / 1000); + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); + const seconds = String(totalSeconds % 60).padStart(2, "0"); + const tenths = Math.floor((ms % 1000) / 100); + return `${minutes}:${seconds}.${tenths}`; +} + +function getTimeBlockType(blockNumber) { + return blockNumber % 2 === 1 ? "minus" : "plus"; +} + +function playerLabel(color) { + if (!state.match) { + return color === "white" ? "Blanc" : "Noir"; + } + + return color === "white" ? state.match.config.whiteName : state.match.config.blackName; +} + +function opponentOf(color) { + return color === "white" ? "black" : "white"; +} + +function logEvent(message, targetMatch = state.match) { + if (!targetMatch) { + return; + } + + const now = new Date(); + targetMatch.history.push({ + message, + time: now.toLocaleTimeString("fr-FR", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + }); +} + +function persistState() { + if (!state.match) { + localStorage.removeItem(STORAGE_KEY); + return; + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.match)); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function sanitizeText(value) { + return String(value || "") + .replace(/[<>]/g, "") + .trim() + .replace(/\s+/g, " "); +} + +function loadDemo() { + refs.setupForm.matchLabel.value = "DĂ©mo officielle ChessCubing"; + refs.setupForm.mode.value = "twice"; + refs.setupForm.preset.value = "freeze"; + refs.setupForm.whiteName.value = "Nora"; + refs.setupForm.blackName.value = "LĂ©o"; + refs.setupForm.arbiterName.value = "Arbitre demo"; + refs.setupForm.eventName.value = "Session tablette"; + refs.setupForm.notes.value = "8 cubes vĂ©rifiĂ©s, variantes prĂŞtes, tirage au sort effectuĂ©."; + renderSetupSummary(); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..66917dc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + web: + build: . + container_name: chesscubing-web + ports: + - "8080:80" + restart: unless-stopped diff --git a/index.html b/index.html new file mode 100644 index 0000000..65b4bb4 --- /dev/null +++ b/index.html @@ -0,0 +1,410 @@ + + + + + + + ChessCubing Arena + + + +
    +
    +
    +
    +
    +

    Application officielle de match

    +

    ChessCubing Arena

    +

    + Une web app pensée pour l'arbitrage sur téléphone et tablette, + directement dérivée des règlements de + ChessCubing Twice et + ChessCubing Time. +

    +
    + + +
    +
    + Mobile-first + Twice + Time + Arbitrage en direct + Fonctionne hors build +
    +
    + + +
    + +
    +
    +
    +
    +

    Préparer la rencontre

    +

    Configuration de match

    +
    +

    + Lancement rapide pour club, démo ou tournoi. Les choix pilotent le + tableau d'arbitrage en direct. +

    +
    + +
    + + +
    + Mode officiel +
    + + +
    +
    + +
    + Cadence du block +
    + + + +
    +
    + + + + + + + + + + + +
    + +
    + + +
    +
    +
    + + +
    + + +
    + + + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..79fd959 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,11 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..7568788 --- /dev/null +++ b/styles.css @@ -0,0 +1,646 @@ +:root { + --bg: #07151f; + --bg-soft: rgba(12, 31, 42, 0.84); + --panel: rgba(10, 26, 37, 0.86); + --panel-border: rgba(255, 255, 255, 0.08); + --panel-highlight: rgba(255, 193, 124, 0.22); + --text: #eef5f2; + --muted: #97adb0; + --warm: #ffb86c; + --warm-strong: #ff8f3c; + --cool: #5de2d8; + --cool-strong: #23bdb0; + --danger: #ff6b6b; + --shadow: 0 24px 70px rgba(0, 0, 0, 0.35); + --radius: 26px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Avenir Next", "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(255, 184, 108, 0.15), transparent 30%), + radial-gradient(circle at bottom right, rgba(93, 226, 216, 0.16), transparent 26%), + linear-gradient(160deg, #030c12 0%, #07151f 46%, #0a2331 100%); +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + background-size: 42px 42px; + mask-image: radial-gradient(circle at center, black 48%, transparent 100%); +} + +.ambient { + position: fixed; + width: 28rem; + height: 28rem; + border-radius: 50%; + filter: blur(70px); + opacity: 0.22; + pointer-events: none; +} + +.ambient-left { + top: -10rem; + left: -8rem; + background: var(--warm-strong); +} + +.ambient-right { + right: -10rem; + bottom: -8rem; + background: var(--cool-strong); +} + +.layout { + position: relative; + width: min(1200px, calc(100% - 2rem)); + margin: 0 auto; + padding: 1.2rem 0 2.4rem; +} + +.hero, +.panel, +.footer { + animation: rise 0.7s ease both; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.95fr); + gap: 1.2rem; + align-items: stretch; + margin-bottom: 1.2rem; +} + +.hero-copy, +.hero-preview, +.panel, +.panel.inset { + border: 1px solid var(--panel-border); + border-radius: calc(var(--radius) + 4px); + background: var(--panel); + backdrop-filter: blur(18px); + box-shadow: var(--shadow); +} + +.hero-copy { + padding: 2rem; +} + +.hero-preview { + padding: 1.4rem; + display: grid; + gap: 1rem; +} + +.eyebrow, +.micro-label { + margin: 0 0 0.4rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--cool); + font-size: 0.76rem; +} + +.micro-label { + color: var(--warm); +} + +h1, +h2, +h3, +strong { + font-family: "Baskerville", "Georgia", serif; +} + +h1 { + margin: 0; + font-size: clamp(2.8rem, 7vw, 4.8rem); + line-height: 0.96; +} + +h2, +h3 { + margin: 0; +} + +.lead, +.section-copy, +.preview-banner p, +.rule-card p, +.preview-list, +.footer p { + color: var(--muted); +} + +.hero-actions, +.setup-actions, +.action-grid, +.result-grid, +.capture-grid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.hero-actions { + margin: 1.6rem 0 1rem; +} + +.hero-pills { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.hero-pills span, +.mini-chip, +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 0.85rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text); +} + +.preview-card, +.preview-banner, +.notice-card, +.double-card, +.rule-card, +.setup-summary, +.timer-card, +.player-card, +.cube-results > div { + padding: 1rem; + border-radius: 22px; + background: + linear-gradient(160deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)); + border: 1px solid rgba(255, 255, 255, 0.07); +} + +.preview-list { + margin: 0.8rem 0 0; + padding-left: 1rem; +} + +.preview-list li + li { + margin-top: 0.45rem; +} + +.workspace { + display: grid; + gap: 1.2rem; +} + +.panel { + padding: 1.4rem; +} + +.panel.inset { + padding: 1.15rem; +} + +.section-heading { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1.2rem; +} + +.setup-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.field { + display: grid; + gap: 0.45rem; +} + +.span-2 { + grid-column: 1 / -1; +} + +legend, +.field > span { + font-weight: 600; +} + +input, +textarea { + width: 100%; + padding: 0.95rem 1rem; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.18); + color: var(--text); + font: inherit; +} + +textarea { + resize: vertical; + min-height: 6rem; +} + +input:focus, +textarea:focus { + outline: 2px solid rgba(93, 226, 216, 0.35); + outline-offset: 1px; +} + +fieldset { + margin: 0; + padding: 0; + border: 0; +} + +.option-grid { + display: grid; + gap: 0.85rem; +} + +.mode-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.preset-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.option-card { + position: relative; + display: grid; + gap: 0.45rem; + min-height: 9rem; + padding: 1rem 1rem 1rem 3rem; + border-radius: 22px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.option-card:hover { + transform: translateY(-3px); + border-color: rgba(255, 184, 108, 0.35); +} + +.option-card input { + position: absolute; + top: 1.2rem; + left: 1rem; + width: 1.1rem; + height: 1.1rem; +} + +.option-card:has(input:checked) { + background: + linear-gradient(160deg, rgba(255, 184, 108, 0.18), rgba(93, 226, 216, 0.08)); + border-color: rgba(255, 184, 108, 0.55); + box-shadow: inset 0 0 0 1px rgba(255, 184, 108, 0.14); +} + +.button { + appearance: none; + border: 0; + border-radius: 16px; + padding: 0.95rem 1.1rem; + font: inherit; + font-weight: 700; + cursor: pointer; + color: var(--text); + transition: transform 160ms ease, filter 160ms ease, background 160ms ease; +} + +.button:hover { + transform: translateY(-2px); + filter: brightness(1.04); +} + +.button:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.button.primary { + background: linear-gradient(135deg, var(--warm-strong), var(--warm)); + color: #1d140a; +} + +.button.secondary { + background: linear-gradient(135deg, rgba(93, 226, 216, 0.18), rgba(93, 226, 216, 0.08)); + border: 1px solid rgba(93, 226, 216, 0.25); +} + +.button.ghost { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.button.capture { + flex: 1 1 12rem; + background: linear-gradient(135deg, rgba(255, 184, 108, 0.16), rgba(255, 184, 108, 0.08)); + border: 1px solid rgba(255, 184, 108, 0.25); +} + +.button.danger { + border-color: rgba(255, 107, 107, 0.25); + color: #ffd7d7; +} + +.button.small { + padding: 0.7rem 0.95rem; +} + +.setup-summary { + display: grid; + gap: 0.35rem; + color: var(--muted); +} + +.live-grid { + display: grid; + grid-template-columns: 1.2fr 0.95fr; + gap: 1rem; +} + +.live-grid > .panel.inset:nth-child(4), +.live-grid > .panel.inset:nth-child(5) { + grid-column: span 1; +} + +.score-panel { + grid-column: 1 / -1; +} + +.score-head { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1rem; +} + +.timer-grid, +.player-grid, +.cube-results, +.rules-grid { + display: grid; + gap: 0.85rem; +} + +.timer-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 0.85rem; +} + +.timer-card strong, +.cube-clock strong { + display: block; + margin: 0.4rem 0; + font-size: clamp(2rem, 6vw, 3.6rem); + line-height: 0.95; +} + +.timer-card.emphasized { + background: + linear-gradient(160deg, rgba(255, 184, 108, 0.22), rgba(255, 255, 255, 0.03)); +} + +.player-grid, +.cube-results, +.rules-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.player-card { + position: relative; + overflow: hidden; +} + +.player-card::after { + content: ""; + position: absolute; + inset: auto -10% -50% auto; + width: 9rem; + height: 9rem; + border-radius: 50%; + opacity: 0.18; +} + +.white-seat::after { + background: var(--warm); +} + +.black-seat::after { + background: var(--cool); +} + +.player-card.active { + border-color: var(--panel-highlight); + box-shadow: 0 0 0 1px rgba(255, 184, 108, 0.2), inset 0 0 0 1px rgba(255, 184, 108, 0.14); + animation: pulse 1.8s ease-in-out infinite; +} + +.player-name-row { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + margin-bottom: 0.35rem; +} + +.player-color { + padding: 0.25rem 0.55rem; + border-radius: 999px; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.1em; + background: rgba(255, 255, 255, 0.08); +} + +.muted { + color: var(--muted); +} + +.notice-card, +.double-card { + margin-top: 1rem; + color: var(--muted); +} + +.double-card strong { + display: block; + margin-bottom: 0.35rem; + color: var(--warm); +} + +.cube-head { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + margin-bottom: 1rem; +} + +.cube-clock { + padding: 1rem; + border-radius: 22px; + background: + linear-gradient(160deg, rgba(93, 226, 216, 0.18), rgba(255, 255, 255, 0.03)); + border: 1px solid rgba(93, 226, 216, 0.18); + margin-bottom: 1rem; +} + +.cube-results { + margin: 1rem 0; +} + +.cube-results strong { + font-size: 1.35rem; + display: block; + margin-top: 0.2rem; +} + +.compact { + margin-top: 0.8rem; +} + +.history-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.75rem; + max-height: 24rem; + overflow: auto; +} + +.history-list li { + padding: 0.85rem 0.95rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.history-list small { + display: block; + margin-bottom: 0.25rem; + color: var(--cool); +} + +.footer { + margin-top: 1rem; + padding: 1rem 0 0; +} + +.footer a { + color: var(--warm); +} + +.hidden { + display: none; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(14px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 1px rgba(255, 184, 108, 0.2), inset 0 0 0 1px rgba(255, 184, 108, 0.14); + } + + 50% { + box-shadow: 0 0 0 1px rgba(255, 184, 108, 0.32), 0 0 32px rgba(255, 184, 108, 0.16); + } +} + +@media (max-width: 960px) { + .hero, + .live-grid, + .mode-grid, + .preset-grid, + .player-grid, + .cube-results, + .rules-grid, + .timer-grid { + grid-template-columns: 1fr; + } + + .setup-form, + .live-grid { + grid-template-columns: 1fr; + } + + .section-heading, + .score-head, + .cube-head { + flex-direction: column; + } +} + +@media (max-width: 640px) { + .layout { + width: min(100% - 1rem, 100%); + } + + .hero-copy, + .hero-preview, + .panel { + padding: 1rem; + border-radius: 22px; + } + + h1 { + font-size: 2.65rem; + } + + .button { + width: 100%; + justify-content: center; + } + + .hero-actions, + .setup-actions, + .action-grid, + .result-grid, + .capture-grid { + display: grid; + } +}