diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 4f6b98b..1efae35 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -3,4 +3,7 @@ # sqlx-macros référence sans l'activer : il n'est jamais compilé ici (vérifié # par `cargo tree -i rsa --target all`, vide). À retirer si rsa apparaît # dans l'arbre des dépendances. -ignore = ["RUSTSEC-2023-0071"] +# libsqlite3-sys 0.30 (CVE SQLite) : même cas, par sqlx-sqlite, jamais compilé +# (`cargo tree -i libsqlite3-sys --target all`, vide). Le correctif demande +# sqlx 0.9. À retirer si libsqlite3-sys apparaît dans l'arbre des dépendances. +ignore = ["RUSTSEC-2023-0071", "RUSTSEC-2026-0459"] diff --git a/Cargo.lock b/Cargo.lock index 69ebe0e..a9e0c06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -82,6 +103,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-compression" +version = "0.4.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -234,6 +267,27 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "brotli" +version = "9.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -372,6 +426,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" + [[package]] name = "const-oid" version = "0.9.6" @@ -443,6 +515,15 @@ dependencies = [ "spin 0.10.1", ] +[[package]] +name = "crc32fast" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.14" @@ -479,9 +560,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" dependencies = [ "cmov", ] @@ -580,7 +661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -709,6 +790,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "flume" version = "0.11.1" @@ -1507,6 +1599,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -1845,7 +1947,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2098,14 +2200,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "once_cell", @@ -2156,7 +2258,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2167,9 +2269,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -2376,6 +2478,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2727,10 +2835,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2780,9 +2888,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -2880,14 +2988,17 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "http", "http-body", "http-body-util", "percent-encoding", "pin-project-lite", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -3234,9 +3345,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -3247,14 +3358,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -3291,7 +3402,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -3532,9 +3643,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", @@ -3622,6 +3733,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 5c953a9..fe522c5 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -31,7 +31,7 @@ subtle = "2.6.1" thiserror = "2.0.21" tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal", "time"] } tower = { version = "0.5.3", features = ["util"] } -tower-http = { version = "0.7.1", features = ["cors", "limit", "set-header", "trace", "timeout"] } +tower-http = { version = "0.7.1", features = ["compression-br", "compression-gzip", "cors", "limit", "set-header", "trace", "timeout"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["json", "env-filter"] } utoipa = { version = "6.0.0", features = ["uuid", "chrono"] } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index b3c573f..c1050ac 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use axum::Router; use axum::http::{HeaderValue, Method, header}; use sqlx::PgPool; +use tower_http::compression::CompressionLayer; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::limit::RequestBodyLimitLayer; use tower_http::set_header::SetResponseHeaderLayer; @@ -57,6 +58,10 @@ pub fn routeur(etat: Arc, origines: &[HeaderValue]) -> Router { routes .with_state(etat) .layer(RequestBodyLimitLayer::new(CORPS_MAX)) + // Réponses compressées (gzip, brotli) selon `Accept-Encoding` : l'état complet + // revient à chaque commande, et pèse sur un réseau lent (ADR 0085). Les jetons + // voyagent en en-tête `Authorization`, jamais en cookie : rien pour BREACH. + .layer(CompressionLayer::new()) .layer(cors) .layer(SetResponseHeaderLayer::overriding( header::CACHE_CONTROL, diff --git a/crates/api/tests/api.rs b/crates/api/tests/api.rs index 2a0be19..cf3a0ea 100644 --- a/crates/api/tests/api.rs +++ b/crates/api/tests/api.rs @@ -238,3 +238,38 @@ async fn compte_les_jours_de_jeu_en_jours_gmt(base: PgPool) { let (_, _, rejeu) = appeler(&banc.routeur, post_pli(&jeton, &pli)).await; assert_eq!(rejeu["etat"]["jours_de_jeu"], 3); } + +#[sqlx::test(migrations = "../../migrations")] +async fn compresse_les_reponses_que_le_client_sait_lire(base: PgPool) { + use http_body_util::BodyExt; + use tower::ServiceExt; + + let banc = banc(base, Limiteurs::default()).await; + let jeton = commun::session(&banc.routeur).await; + let requete = |encodage: Option<&str>| { + let mut r = Request::get("/etat").header(header::AUTHORIZATION, format!("Bearer {jeton}")); + if let Some(e) = encodage { + r = r.header(header::ACCEPT_ENCODING, e); + } + r.body(Body::empty()).unwrap() + }; + // Réseau lent (ADR 0085) : l'état complet voyage compressé quand le client l'accepte. + let reponse = banc + .routeur + .clone() + .oneshot(requete(Some("gzip"))) + .await + .unwrap(); + assert_eq!(reponse.status(), StatusCode::OK); + assert_eq!(reponse.headers()[header::CONTENT_ENCODING], "gzip"); + assert_eq!(reponse.headers()[header::CACHE_CONTROL], "no-store"); + let compresse = reponse.into_body().collect().await.unwrap().to_bytes(); + // Sans `Accept-Encoding`, rien ne change. + let (statut, entetes, corps) = appeler(&banc.routeur, requete(None)).await; + assert_eq!(statut, StatusCode::OK); + assert!(entetes.get(header::CONTENT_ENCODING).is_none()); + assert!( + compresse.len() < corps.to_string().len(), + "le corps compressé doit être plus court" + ); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index d8494ad..06113e1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -187,6 +187,23 @@ Les 53 animations de `plans/todo/animations-propositions.html`, avec la version | Compteurs : glissé court (480 ms) ; mouvement réduit : fondus de 120 ms, révélation du pli en trois temps | fait | | Réglage « Sons et vibrations » (activés, coupés), `data-retours` sur `` | fait | +## Résilience réseau (ADR 0085, proposé) + +Pour jouer dans le train : réseau lent, pertes de paquets, micro-coupures. L’[ADR 0085](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0085-reseau-instable-meme-commande-et-table-hors-connexion.md) remplace partiellement l’ADR 0041. + +| Tâche | État | +|---|---| +| Client de l’API : 8 s au plus par essai, nouveaux essais à 0,5, 1, 2 et 4 s (part de hasard, 15 s au plus) sur coupure, `502`, `504`, `503` hors API et `429` court ; même corps donc même clé ; jamais sur une réponse du serveur | fait | +| Une clé par geste (fusion, pli, sabliers, indices, récompenses, ricochets), réutilisée par « Réessayer » tant que le serveur n’a pas répondu | fait | +| État réseau à trois niveaux (en ligne, instable, hors ligne) déduit des requêtes ; hors ligne après 2 s de coupure annoncée ou une commande sans réponse ; sonde `/sante` de 2 à 30 s | fait | +| Resynchronisation au retour du réseau et au retour au premier plan (plus de 30 s) ; un catalogue lu n’est pas effacé par une resynchronisation ratée ; jeton renouvelé en arrière-plan, une seule ouverture de session à la fois | fait | +| Table préparée hors connexion (pose, déplacement, retrait, réserve) ; fusion, plis, boutique, indices, récompenses et ricochets inertes | fait | +| Attente par paliers : briques qui se rapprochent après 1 s, « Le réseau est lent, nous insistons… » après 4 s (table et rituel du pli) ; erreur réseau sans secousse, icône hors ligne et « Réessayer » ; vérifié image par image (`animation-check`), mouvement réduit sans mouvement | fait | +| Pastille dans l’en-tête de chaque écran : « Réseau instable », « Hors ligne · à jour il y a 4 min », « De retour en ligne » 2 s | fait | +| Préchargement des silhouettes et jaquettes des fascicules parus à l’inactivité (en ligne, sans `saveData`) ; `navigator.storage.persist()` | fait | +| API : réponses compressées (gzip, brotli) selon `Accept-Encoding` | fait | +| Maquette I-04 (pastille) à aligner dans le dossier design | à faire | + ## Outillage hors-prod | Tâche | État | diff --git a/plans/closed/2026-11-10-feat-resilience-reseau.md b/plans/closed/2026-11-10-feat-resilience-reseau.md new file mode 100644 index 0000000..d1efa00 --- /dev/null +++ b/plans/closed/2026-11-10-feat-resilience-reseau.md @@ -0,0 +1,98 @@ +# Feat — Résilience aux réseaux lents et aux micro-coupures + +- Statut : closed +- Créé le : 2026-11-10 +- Démarré le : 2026-11-10 +- Terminé le : 2026-11-10 +- Dépôts : design, app +- Branches : design → `claude/offline-network-resilience-wsnm09` ; app → `claude/offline-network-resilience-wsnm09` + +## Contexte et objectif + +Le propriétaire joue dans le train : réseau lent, pertes de paquets, micro-coupures. Aujourd’hui : + +- `fetch` n’a aucun délai maximal (`web/src/services/api.ts`) : une requête peut pendre des dizaines de secondes ; +- aucune requête n’est retentée, et chaque essai tire une nouvelle clé de commande ; +- `navigator.onLine` ment (vrai dans un tunnel), la bannière hors ligne clignote ou n’apparaît jamais ; +- aucune resynchronisation au retour du réseau ni au retour au premier plan ; +- une erreur réseau s’affiche comme un retour de jeu générique ; +- hors ligne, la table est inerte (ADR 0041), alors qu’elle est un état purement local. + +Point d’appui : côté serveur, `debuter_commande` (`crates/api/src/routes.rs`) cherche la commande déjà traitée **avant** de vérifier `version_etat` : renvoyer la même clé rejoue la même réponse, sans double effet. + +## Décisions prises + +Le propriétaire a validé l’ensemble des propositions (« Go pour l’ensemble des propositions », 2026-11-10) : + +1. Délai par essai et nouveaux essais avec la même clé, pendant que le joueur attend. +2. État réseau à trois niveaux (en ligne, instable, hors ligne), déduit des requêtes, avec sonde `/sante`. +3. Resynchronisation au retour du réseau et au retour au premier plan ; renouvellement anticipé du jeton. +4. Préchargement des médias du catalogue à l’inactivité ; `navigator.storage.persist()`. +5. Compression des réponses de l’API (gzip, brotli). +6. Attente par paliers (1 s, 4 s), erreur réseau distincte d’un échec de jeu avec « Réessayer » (même clé), table gardée. +7. Pastille d’état dans l’en-tête, « De retour en ligne », heure de la dernière synchronisation. +8. Table préparée hors connexion : nouvel ADR qui remplace partiellement l’ADR 0041 et précise que le renvoi d’une même commande n’est pas un rejeu. + +## Périmètre / hors-périmètre + +- Dans le périmètre : PWA (services, composants, styles), API (couche de compression), ADR et roadmap. +- Hors périmètre : file d’attente hors connexion (toujours exclue), fusion ou pli hors ligne, compression ou HTTP/3 au CDN (Infra), changement de contrat. + +## Éléments à développer + +- [x] design : ADR 0085 « Réseau instable : même commande renvoyée et table préparée hors connexion », remplace partiellement l’ADR 0041 ; index, statut de l’ADR 0041, `adr.html` régénéré, `game-design.md` si besoin. +- [x] app `crates/api` : `CompressionLayer` (gzip, br) dans `lib.rs`. +- [x] app `web/src/services/reseau.ts` : magasin de l’état réseau (trois niveaux, sonde avec délai croissant, temporisation de 2 s du passage hors ligne, retour en ligne signalé), hooks `useReseau`, `useEnLigne`. +- [x] app `web/src/services/api.ts` : délai par essai (8 s), nouveaux essais (0,5, 1, 2, 4 s avec part de hasard) sur erreur réseau, `502`, `504`, `503` sans corps de l’API, `429` avec `Retry-After` court ; corps identique donc même clé ; résultats signalés au magasin ; session dédupliquée et renouvelée en arrière-plan. +- [x] app `web/src/services/partie.ts` : clé tirée une fois par geste (`cleDuGeste`), réutilisée par « Réessayer » ; erreur réseau `ton: 'reseau'` ; resynchronisation au retour du réseau et au premier plan, sans écraser un catalogue lisible ; table modifiable hors ligne ; heure de la dernière synchronisation. +- [x] app `web/src/services/prechargement.ts` : médias du catalogue chargés à l’inactivité, en ligne, sans `saveData`, trois à la fois ; `storage.persist()`. +- [x] app composants : palier d’attente (`useAttente`), briques qui se rapprochent sur la table, ligne « Le réseau est lent… », retour réseau avec « Réessayer » dans `Resultat`, statut lent dans le rituel du pli, `PastilleReseau` dans l’en-tête de chaque écran, textes hors ligne mis à jour. +- [x] app styles : pastille, retour réseau, attente de la table ; mouvement réduit respecté. +- [x] app tests : `api.test.ts` (délai, nouveaux essais, même corps, pas de nouvel essai sur 4xx), `reseau.test.ts`, `App.test.tsx` (table hors ligne, fusion inerte). +- [x] app roadmap : section « Résilience réseau ». + +## Cas limites et risques + +- Une fusion sans découverte n’est pas enregistrée côté serveur : la renvoyer la réévalue, sans effet. Sans risque. +- `503` « aucun catalogue publié » de l’API : pas de nouvel essai (corps JSON de l’API). +- `429` : nouvel essai seulement si `Retry-After` ≤ 5 s. +- Resynchronisation concurrente : une seule à la fois. +- Un catalogue illisible pendant une resynchronisation ne remplace pas un catalogue déjà prêt. + +## Vérification + +```sh +cd app && cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace +cargo sqlx prepare --workspace --check -- --all-targets +cd web && npm run lint && npm run typecheck && npm test +cargo run -q --manifest-path ../design/outils/Cargo.toml --bin check_identity -- web/src/styles.css +cd design && cargo run -q --manifest-path outils/Cargo.toml --bin build_adr +``` + +Recette : Chromium avec réseau ralenti et coupé (Playwright `route` / `setOffline`), table hors ligne, fusion qui réussit après une coupure, pastille. + +## Critères d’acceptation + +- Une coupure de moins de 15 s pendant une fusion n’affiche aucune erreur : la fusion aboutit. +- Une erreur réseau garde la table et propose « Réessayer » ; aucune secousse. +- Hors ligne, on pose, déplace et retire des briques ; « Fusionner », plis, boutique et indices restent inertes. +- Au retour du réseau, l’état se resynchronise seul. +- Tous les contrôles d’« Avant de conclure » passent. + +## Livraison + +Commits en anglais `type(2026-11-10): …` avec `Co-Authored-By`, poussés sur la branche de chaque dépôt, PR en brouillon. + +## Journal + +- Le dossier `plans/` commun à l’espace de travail n’existe pas : le plan vit dans `App/plans/`. +- Ajout en recette : une réponse qui tarde plus de 3 s rend le réseau instable sans attendre qu’elle arrive (la pastille s’affichait trop tard). +- `animation-check` : sur mobile, les briques glissaient à l’horizontale ; la règle `rapproche-v` manquait de spécificité. Corrigé, réenregistré, mouvement réduit vérifié. +- Le statut « Prête, en attente du réseau » se coupait sur mobile : « En attente du réseau ». +- Les tests de l’App isolent leur client (`delais: []`, `Reseau` à part) : un appel non simulé ne fait plus fuir un état hors ligne d’un test à l’autre. + +## Bilan + +- Fait : tous les éléments du plan, dans Design (ADR 0085, ADR 0041 partiellement remplacé, I-04, `adr.html`) et App (PWA, API, tests, roadmap). +- Vérifié : `cargo fmt`, `clippy`, `cargo test` (dont la compression), `cargo sqlx prepare --check`, `npm run lint`, `typecheck`, `npm test` (131 tests), `check_identity` sans écart, recette Chromium (réseau lent, micro-coupure absorbée avec la même clé, perte puis « Réessayer », hors ligne, retour en ligne, ordinateur). +- Reste à faire : maquette I-04 dans le dossier design (pastille) ; compression du catalogue au CDN (dépôt Infra) si elle n’est pas déjà active. diff --git a/web/package-lock.json b/web/package-lock.json index 217d8ce..80844c8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1899,9 +1899,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { @@ -2278,9 +2278,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.18.tgz", + "integrity": "sha512-ubTG+4KlidvioxjOFdsdEa2F6JVGZb8m2ByJUqQ7fYzUeT1cEn194POcsQ10PKH4p//q0YQn4SB3YpSuzcA1Yg==", "cpu": [ "arm" ], @@ -2295,9 +2295,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.18.tgz", + "integrity": "sha512-2Xa1t/JeFGTZn5TbzgPppcCftF8sCVnTNvrIvoSdcZhoFzAuVy1L3xxQUQ4sfWDtwVzg1u6Yi1R3SlJugRNV2Q==", "cpu": [ "arm64" ], @@ -2312,9 +2312,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.18.tgz", + "integrity": "sha512-iUzw9HshVvrWci9gVXjxWS7/M/IcLvNo67vu2c/w9zKBz4hIvPRixdUpIW/iCfhldLaq9ER2hecsI9UY5z0/yA==", "cpu": [ "arm64" ], @@ -2329,9 +2329,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.18.tgz", + "integrity": "sha512-XuDHFzbFEtZl0QGJi92+fmG67tfnp1iZ9VOeDkwasGyTq2VG3/5PnJnvAr+MTzFh/rNScJ3VhN+XpZyyGsN7Jw==", "cpu": [ "x64" ], @@ -2346,9 +2346,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.18.tgz", + "integrity": "sha512-MM8opLfsLK15oEXDz6iBc0t3ahu+ew9jsAmha+ovZ33hSwYgqo7YmpRITqFTfW7xA71BLEQ3jJdTsaCmhB1c9w==", "cpu": [ "x64" ], @@ -2363,9 +2363,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.18.tgz", + "integrity": "sha512-J6EoCWiy5RnFQzHrKyHXXevZ9lukQfGpnchX/zG/k/WC2nc9O9XyS1JgA8kI9VxMFggMbCPjpv7LcikriXfehw==", "cpu": [ "arm" ], @@ -2380,9 +2380,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.18.tgz", + "integrity": "sha512-HIkFuLF7CdDeki+1aCZLLbLq0hk/PHntb+z4/YCTo47JbksTD6jJEELIz92tYROmDmvMrNqUMqBi71BkJLDR+A==", "cpu": [ "arm64" ], @@ -2397,9 +2397,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.18.tgz", + "integrity": "sha512-4oqWItLLYt7IceOnElCct8T2ek5p7PmD7LN04TRJRXGo5f3HtKTgGH6PhYRFK4C2ICkq/LMUU0BVPvvlAKhm8g==", "cpu": [ "arm64" ], @@ -2414,9 +2414,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.18.tgz", + "integrity": "sha512-+AkRly5SrCEgnPhGRfub7pucASYEC2fTsxZg7VCcnozpA2+Wn/gCYy1t7LuJS67/xyJu/7noYBJncDUkbwgw2A==", "cpu": [ "ppc64" ], @@ -2431,9 +2431,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.18.tgz", + "integrity": "sha512-Ttg1r9vVykMnQrE/GwYO9icKtXiuiMfRe/w74xJkeoVuvhRG+WtT6LEMOf58Yl63yd77lhHzO8Y55R8CC3XALA==", "cpu": [ "s390x" ], @@ -2448,9 +2448,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.18.tgz", + "integrity": "sha512-xX1Tt4o5jUMma0vJsXp5eRZA9nOuyigb2dKJMMzZRmzMxLYGE9PaKKSqHPzPvSQgM8ZOEacSTQwDQcwWfh0xmQ==", "cpu": [ "x64" ], @@ -2465,9 +2465,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.18.tgz", + "integrity": "sha512-4VVlGI5K2kociZlW3lSN8VjG3TW5yQIlAcfCdBXMgGjJxZll15Fa4ocRHXHvIVVnwlibCw/NQajJrNDiqqpiiw==", "cpu": [ "x64" ], @@ -2482,9 +2482,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.18.tgz", + "integrity": "sha512-PApTsgH1jLYuyy3tSCz9De82Gq5qXwi7vImVZhEzTgX1C4150Cp5M2ZfL3pLvbCvbEHL0oJeC6pd6podtv8Y5w==", "cpu": [ "arm64" ], @@ -2499,9 +2499,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.18.tgz", + "integrity": "sha512-J2XjWjx/SP+4x0H4r6dz3Vzb5WVkg7re40Do9K7HJ3sK2/xcmZrWwakXlSwaMvOkvruLJCD4CL/3wqsIR/thgg==", "cpu": [ "arm64" ], @@ -2516,9 +2516,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.18.tgz", + "integrity": "sha512-XwRJxRQvkuEtS0hIIt29QrqPJ+JzMJSNYHJw4ae/mc6uEarfRG1ceyg4HmmWiBdL/YCb6g7N5BLbSvmRJRYZHw==", "cpu": [ "x64" ], @@ -3245,22 +3245,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", - "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.2", + "@vitest/spy": "5.1.0", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -3273,9 +3273,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", - "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", "dev": true, "license": "MIT", "funding": { @@ -3661,9 +3661,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -4254,9 +4254,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -5659,9 +5659,9 @@ } }, "node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", "dev": true, "license": "MIT", "dependencies": { @@ -5726,9 +5726,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -5799,9 +5799,9 @@ } }, "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -6014,9 +6014,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -6034,9 +6034,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6259,13 +6259,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.18.tgz", + "integrity": "sha512-C1uuT3UR60SShuCaPQFAAZyefJCWZlAKB02BV3BuPE2Rs4fevRQltEKoFO2tV5W9/eJhU5RieV6vJj3euulfNQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6275,21 +6275,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.18", + "@rolldown/binding-android-arm64": "1.2.18", + "@rolldown/binding-darwin-arm64": "1.2.18", + "@rolldown/binding-darwin-x64": "1.2.18", + "@rolldown/binding-freebsd-x64": "1.2.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.18", + "@rolldown/binding-linux-arm64-gnu": "1.2.18", + "@rolldown/binding-linux-arm64-musl": "1.2.18", + "@rolldown/binding-linux-ppc64-gnu": "1.2.18", + "@rolldown/binding-linux-s390x-gnu": "1.2.18", + "@rolldown/binding-linux-x64-gnu": "1.2.18", + "@rolldown/binding-linux-x64-musl": "1.2.18", + "@rolldown/binding-openharmony-arm64": "1.2.18", + "@rolldown/binding-win32-arm64-msvc": "1.2.18", + "@rolldown/binding-win32-x64-msvc": "1.2.18" } }, "node_modules/rollup": { @@ -6423,9 +6423,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", - "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.4.tgz", + "integrity": "sha512-FqlEuoTq/zRmA4Lg6aOqyH3lCHbUtCB0bmHCeFniX2GBdDqMJM3FwdvEhlE5Pim1dlt2FfBdVPmUNNiGhhKqpw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6645,9 +6645,9 @@ } }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -6879,9 +6879,9 @@ } }, "node_modules/tinybench": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", - "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", "dev": true, "license": "MIT", "engines": { @@ -7085,9 +7085,9 @@ } }, "node_modules/undici": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", - "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "version": "8.12.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz", + "integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==", "dev": true, "license": "MIT", "engines": { @@ -7218,16 +7218,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "version": "8.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.7.tgz", + "integrity": "sha512-oxh0dMxrayBr33ScJvvBJ2HBQ2jc1TBrXKR2NyL/MJ9WPuX19mN2DXWUmywKdyw5RFVQBo8hmtb2FMC0LHrnng==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", + "postcss": "^8.5.32", + "rolldown": "~1.2.18", "tinyglobby": "^0.2.17" }, "bin": { @@ -7244,7 +7244,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -7327,25 +7327,25 @@ } }, "node_modules/vitest": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", - "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.2", - "chai": "^6.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", - "why-is-node-running": "^3.2.1" + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -7360,12 +7360,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.2", - "@vitest/browser-preview": "5.0.2", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.2", - "@vitest/coverage-v8": "5.0.2", - "@vitest/ui": "5.0.2", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -7563,9 +7563,9 @@ } }, "node_modules/why-is-node-running": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", - "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", "bin": { diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 9707653..df263a4 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -6,10 +6,14 @@ import type { ReponseFusion, ReponsePli } from './contrat' import { ClientApi, ErreurApi } from './services/api' import type { LecteurCatalogue } from './services/catalogue' import { effacerPartie } from './services/local' +import { Reseau, reseau } from './services/reseau' import { GEO, LOGIE, MOT, RECETTE, etat, ex, lecteurFictif } from './test/fixtures' +/** Un appel non simulé échoue tout de suite, sans toucher l'état réseau de l'application. */ +const isole = () => ({ delais: [], reseau: new Reseau() }) + function client(fusion?: ClientApi['fusion']): ClientApi { - const api = new ClientApi('https://api.exemple.test') + const api = new ClientApi('https://api.exemple.test', undefined, isole()) vi.spyOn(api, 'etat').mockResolvedValue(etat()) vi.spyOn(api, 'sante').mockResolvedValue({ statut: 'ok', version_catalogue: 'v1', version_api: '0', revision_api: '', reinitialisation: false }) if (fusion) vi.spyOn(api, 'fusion').mockImplementation(fusion) @@ -214,7 +218,7 @@ describe('table de fusion', () => { }) it('relie les cartes du codex qui se font référence, avec un fil d’Ariane', async () => { - const api = new ClientApi('https://api.exemple.test') + const api = new ClientApi('https://api.exemple.test', undefined, isole()) vi.spyOn(api, 'etat').mockResolvedValue( etat({ decouvertes: [{ mot_id: MOT, recette_id: RECETTE, numero: 1, decouverte_le: '2026-10-02T10:00:00Z' }] }), ) @@ -269,14 +273,49 @@ describe('table de fusion', () => { expect(api.etat).toHaveBeenCalledTimes(2) }) - it('hors ligne, consulte seulement : rien ne se pose', async () => { - vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) - render() - expect(await screen.findByRole('button', { name: /^Réserve/ })).toBeDisabled() - fireEvent.click(screen.getByRole('button', { name: 'Emplacement 1 vide. Choisir une brique' })) - expect(screen.queryByRole('dialog', { name: /^Réserve/ })).not.toBeInTheDocument() - expect(screen.getAllByText(/consultation seulement/).length).toBeGreaterThanOrEqual(1) + it('hors ligne, prépare la table mais ne fusionne pas (ADR 0085)', async () => { + reseau.reinitialiser(false) + const fusion = vi.fn() + render() + expect(screen.getAllByText('Hors ligne').length).toBeGreaterThanOrEqual(1) + await poser('géo-', '-logie') + expect(screen.getByRole('button', { name: 'Emplacement 1 : géo-. Retirer' })).toBeInTheDocument() expect(fusionner()).toBeDisabled() + expect(screen.getByText('En attente du réseau')).toBeInTheDocument() + expect(fusion).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: /^Réserve/ })).toBeEnabled() + }) + + it('au retour du réseau, se resynchronise et rend la fusion possible', async () => { + reseau.reinitialiser(false) + const api = client() + render() + await poser('géo-', '-logie') + await waitFor(() => expect(api.etat).toHaveBeenCalledTimes(1)) + act(() => reseau.noterReussite(10)) + await waitFor(() => expect(api.etat).toHaveBeenCalledTimes(2)) + expect(fusionner()).toBeEnabled() + expect(screen.getAllByText('De retour en ligne').length).toBeGreaterThanOrEqual(1) + }) + + it('une perte du réseau garde la table, ne secoue rien et propose de réessayer avec la même clé', async () => { + document.documentElement.dataset.mouvement = 'reduit' + const recu: string[] = [] + const fusion = vi.fn(async (d) => { + recu.push(d.commande) + if (recu.length === 1) throw new ErreurApi(0, 'reseau', 'API injoignable') + return { issue: { type: 'echec' }, etat: etat() } as ReponseFusion + }) + render() + await poser('géo-', '-logie') + await act(async () => fireEvent.click(fusionner())) + expect(await screen.findByText(/La connexion s’est perdue/)).toBeInTheDocument() + expect(document.querySelector('.slots.shake')).toBeNull() + expect(screen.getByRole('button', { name: 'Emplacement 1 : géo-. Retirer' })).toBeInTheDocument() + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Réessayer' }))) + await waitFor(() => expect(fusion).toHaveBeenCalledTimes(2)) + expect(recu[1]).toBe(recu[0]) + delete document.documentElement.dataset.mouvement }) it('vide une case qu’on touche sans déplacer l’autre, et réordonne au clavier', async () => { @@ -294,7 +333,7 @@ describe('table de fusion', () => { }) it('dit quand aucun mot n’est plus possible, et mène aux plis', async () => { - const api = new ClientApi('https://api.exemple.test') + const api = new ClientApi('https://api.exemple.test', undefined, isole()) vi.spyOn(api, 'etat').mockResolvedValue(etat({ reserve: [ex(GEO, 2), ex(LOGIE, 0)] })) render() expect(await screen.findByText('Aucun mot possible pour l’instant')).toBeInTheDocument() @@ -313,7 +352,7 @@ describe('table de fusion', () => { }) it('revient au même écran après un rechargement', async () => { - const api = new ClientApi('https://api.exemple.test') + const api = new ClientApi('https://api.exemple.test', undefined, isole()) vi.spyOn(api, 'etat').mockResolvedValue( etat({ decouvertes: [{ mot_id: MOT, recette_id: RECETTE, numero: 1, decouverte_le: '2026-10-02T10:00:00Z' }] }), ) @@ -365,7 +404,7 @@ describe('table de fusion', () => { it('montre le cabinet du joueur : ses nombres seuls, sans total des légendaires', async () => { window.location.hash = '#/codex?vue=objectifs' - const api = new ClientApi('https://api.exemple.test') + const api = new ClientApi('https://api.exemple.test', undefined, isole()) vi.spyOn(api, 'etat').mockResolvedValue(etat({ jours_de_jeu: 12 })) render() // Une ancienne adresse « Objectifs » ouvre le cabinet. @@ -382,7 +421,7 @@ describe('table de fusion', () => { it('joint les segments d’une famille complète et montre le mot au plus long voyage', async () => { window.location.hash = '#/codex?vue=cabinet' - const api = new ClientApi('https://api.exemple.test') + const api = new ClientApi('https://api.exemple.test', undefined, isole()) vi.spyOn(api, 'etat').mockResolvedValue( etat({ decouvertes: [{ mot_id: MOT, recette_id: RECETTE, numero: 1, decouverte_le: '2026-10-02T10:00:00Z' }] }), ) @@ -398,7 +437,7 @@ describe('table de fusion', () => { }) it('ouvre « En savoir plus » depuis la carte vue au codex, jusque dans l’adresse', async () => { - const api = new ClientApi('https://api.exemple.test') + const api = new ClientApi('https://api.exemple.test', undefined, isole()) vi.spyOn(api, 'etat').mockResolvedValue( etat({ decouvertes: [{ mot_id: MOT, recette_id: RECETTE, numero: 1, decouverte_le: '2026-10-02T10:00:00Z' }] }), ) diff --git a/web/src/App.tsx b/web/src/App.tsx index 2b9c0c1..b02d438 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,7 +24,10 @@ import { useEnergieAnimee } from './services/animation' import { useMaintenant } from './services/horloge' import { energieA } from './services/plis' import { quetesDeFascicule, recompensesARecuperer } from './services/quetes' -import { useEnLigne } from './services/reseau' +import { useReseau } from './services/reseau' +import { useAttente } from './services/attente' +import { prechargerAuRepos } from './services/prechargement' +import { PastilleReseau } from './composants/PastilleReseau' interface Props { api: ClientApi @@ -32,18 +35,29 @@ interface Props { } /** En-tête d'un écran (maquettes) : son titre en serif et ses outils. */ -function EnTete({ titre, id, children }: { titre: string; id: string; children?: React.ReactNode }) { +function EnTete({ titre, id, pastille, children }: { titre: string; id: string; pastille?: React.ReactNode; children?: React.ReactNode }) { return (

{titre}

- {children &&
{children}
} + {(children || pastille) && ( +
+ {pastille} + {children} +
+ )}
) } +/** Hors ligne, la table se prépare ; la fusion attend le réseau (ADR 0085). */ +const HORS_LIGNE_TABLE = 'Hors ligne : préparez votre table, la fusion attend le réseau.' +const HORS_LIGNE_ACTION = 'Hors ligne : cette action attend le réseau.' + export function App({ api, catalogue }: Props) { - const enLigne = useEnLigne() + const { niveau } = useReseau() + const enLigne = niveau !== 'hors_ligne' const partie = usePartie(api, catalogue, enLigne) + const attente = useAttente(partie.enAttente) const { reglages, changer } = useReglages() // L'écran vit dans l'URL : un rechargement ramène exactement au même endroit. const [route, naviguer] = useRoute() @@ -79,14 +93,11 @@ export function App({ api, catalogue }: Props) { ) const exemplaires = (etat?.reserve.reduce((n, e) => n + e.exemplaires, 0) ?? 0) - partie.table.length - const bloquee = !enLigne - ? 'Hors ligne : consultation seulement. Reconnectez-vous pour jouer.' - : !etat - ? 'Votre réserve n’est pas encore chargée.' - : null - const prete = bloquee === null && partie.table.length >= TABLE_MIN - const statut = prete ? 'Hypothèse prête' : partie.table.length === 1 ? 'Une brique posée' : 'En attente' - const raisonFusion = bloquee ?? (partie.table.length < TABLE_MIN ? 'Posez deux briques pour fusionner.' : null) + // Hors ligne, la table reste à portée : seule la fusion attend le réseau. + const bloquee = !etat ? 'Votre réserve n’est pas encore chargée.' : null + const raisonFusion = bloquee ?? (partie.table.length < TABLE_MIN ? 'Posez deux briques pour fusionner.' : !enLigne ? HORS_LIGNE_TABLE : null) + const prete = raisonFusion === null + const statut = prete ? 'Hypothèse prête' : partie.table.length === 1 ? 'Une brique posée' : partie.table.length >= TABLE_MIN && !enLigne ? 'En attente du réseau' : 'En attente' // Sans silhouette à indicer, l'indice n'afficherait rien : le bouton disparaît. const indiceDisponible = catalogueJeu ? indiceCourant(catalogueJeu, entrees, etat) !== null : false const bloque = @@ -128,6 +139,10 @@ export function App({ api, catalogue }: Props) { focusFusion.current = false boutonFusion.current?.focus({ preventScroll: true }) }, [reserveOuverte]) + // Pendant que le réseau est bon, les médias des fascicules parus se gardent pour le tunnel suivant. + const prechargeable = catalogueJeu !== null && niveau === 'en_ligne' + useEffect(() => (prechargeable && catalogueJeu ? prechargerAuRepos(catalogueJeu, catalogue) : undefined), [prechargeable, catalogueJeu, catalogue]) + const pastille = const ecran = (id: Onglet) => ({ hidden: onglet !== id, className: `ecran ecran-${id}`, 'aria-labelledby': `titre-${id}` }) return ( @@ -141,16 +156,12 @@ export function App({ api, catalogue }: Props) {
- {!enLigne && ( -

- - Hors ligne : consultation seulement. Reconnectez-vous pour jouer. -

- )} {partie.erreurEtat && (

- {partie.erreurEtat} Votre progression confirmée n’est pas perdue. + + {partie.erreurEtat} Votre progression confirmée n’est pas perdue{enLigne ? '.' : ' : elle se mettra à jour au retour du réseau.'} +

)} {jeu.etat === 'erreur' && ( @@ -177,7 +188,7 @@ export function App({ api, catalogue }: Props) { {catalogueJeu && ( <>
- + {reserveOuverte && etat && ( void partie.fusionner()} onInverser={partie.inverser} aRecuperer={jalonsARecuperer} onQuetes={() => naviguer({ onglet: 'quetes', quete: 'fascicules', carte: null, pli: null, plus: false })} @@ -280,7 +294,7 @@ export function App({ api, catalogue }: Props) {
- +
- +
- + catalogue.url(chemin)} recuperer={partie.recupererJalon} recupererPalier={partie.recupererPalier} - inactif={enLigne ? null : 'Hors ligne : consultation seulement. Reconnectez-vous pour jouer.'} + inactif={enLigne ? null : HORS_LIGNE_ACTION} />
- +
@@ -353,12 +367,12 @@ export function App({ api, catalogue }: Props) { )}
- +
- +
diff --git a/web/src/composants/Icone.tsx b/web/src/composants/Icone.tsx index 31a679d..43782d1 100644 --- a/web/src/composants/Icone.tsx +++ b/web/src/composants/Icone.tsx @@ -33,6 +33,22 @@ const TRACES = { ), + // Ondes du réseau : la pleine pour « de retour en ligne », une seule onde pour un réseau instable. + reseau: ( + <> + + + + + + ), + reseauFaible: ( + <> + + + + + ), info: ( <> diff --git a/web/src/composants/PastilleReseau.tsx b/web/src/composants/PastilleReseau.tsx new file mode 100644 index 0000000..6c66a79 --- /dev/null +++ b/web/src/composants/PastilleReseau.tsx @@ -0,0 +1,50 @@ +import { depuis, useMaintenant } from '../services/horloge' +import { useReseau } from '../services/reseau' +import { Icone } from './Icone' + +/** + * Pastille de l'état du réseau dans l'en-tête d'un écran (ADR 0085) : une icône + * et un mot, jamais la couleur seule. Rien en ligne, sauf « De retour en ligne » + * pendant 2 s ; hors ligne, depuis quand la partie est à jour. + */ +export function PastilleReseau({ synchroniseLe }: { synchroniseLe: number | null }) { + const { niveau, retour } = useReseau() + const maintenant = useMaintenant() + const visible = niveau !== 'en_ligne' || retour + let contenu: React.ReactNode = null + if (niveau === 'hors_ligne') { + contenu = ( + <> + + Hors ligne + {synchroniseLe !== null && · à jour {depuis(synchroniseLe, maintenant)}} + + ) + } else if (retour) { + contenu = ( + <> + + De retour en ligne + + ) + } else if (niveau === 'instable') { + contenu = ( + <> + + Réseau instable + + ) + } + const titre = + niveau === 'hors_ligne' + ? 'Hors ligne : consultez votre codex et préparez votre table. La fusion, les plis et la boutique attendent le réseau.' + : niveau === 'instable' + ? 'Réseau instable : vos actions peuvent prendre un peu plus de temps. Rien n’est perdu.' + : undefined + // La région reste dans la page : les lecteurs d'écran annoncent ce qui y change. + return ( +

+ {contenu} +

+ ) +} diff --git a/web/src/composants/Plis.tsx b/web/src/composants/Plis.tsx index f691aa1..1627f33 100644 --- a/web/src/composants/Plis.tsx +++ b/web/src/composants/Plis.tsx @@ -3,6 +3,7 @@ import { type CSSProperties, Fragment, type ReactNode, useCallback, useEffect, u import type { Etat, OuverturePli, Rarete, ReponseAchatSablier, ReponsePli, ReponseSablier } from '../contrat' import { accordLegendaire, basculer, mouvementReduit, useEnergieAnimee, vibrer } from '../services/animation' import { useMaintenant } from '../services/horloge' +import { useAttente } from '../services/attente' import { useEnLigne } from '../services/reseau' import { type CatalogueJeu, type FasciculeParu, NATURES, decouvertePossible, libelleFascicule } from '../services/jeu' import { @@ -298,6 +299,9 @@ function Atelier({ jeu, etat, fascicule, onRetour, ouvrirPli, raccourci, onEssay const [lignes, setLignes] = useState([]) const [reponse, setReponse] = useState(null) const [statut, setStatut] = useState('') + /** Vrai tant que le serveur n'a pas répondu à l'ouverture : la cire chauffe, et après 4 s un mot l'explique (ADR 0085). */ + const [enCharge, setEnCharge] = useState(false) + const palier = useAttente(enCharge) const passer = useRef(false) const occupe = useRef(false) const scene = useRef(null) @@ -346,7 +350,8 @@ function Atelier({ jeu, etat, fascicule, onRetour, ouvrirPli, raccourci, onEssay setTeteGelee(teteReelle.current) setEtatGele(etat) // La requête part tout de suite ; rien n'est annoncé avant sa réponse. - const demande = ouvrirPli(fascicule.manifeste.id) + setEnCharge(true) + const demande = ouvrirPli(fascicule.manifeste.id).finally(() => setEnCharge(false)) setClasse('pk-charging') const [resultat] = await Promise.all([demande, pause(260)]) if ('erreur' in resultat) { @@ -579,7 +584,7 @@ function Atelier({ jeu, etat, fascicule, onRetour, ouvrirPli, raccourci, onEssay ))}

- {statut || (etapeAffichee === 'recharging' ? (ouvrable ? `Pli en recharge · ${energie.texte}` : 'Ce pli n’est pas encore disponible') : 'Touchez le pli pour l’ouvrir')} + {(palier === 2 ? 'Le réseau est lent, nous insistons…' : statut) || (etapeAffichee === 'recharging' ? (ouvrable ? `Pli en recharge · ${energie.texte}` : 'Ce pli n’est pas encore disponible') : 'Touchez le pli pour l’ouvrir')}

{etape !== 'revealed' && (
diff --git a/web/src/composants/Resultat.tsx b/web/src/composants/Resultat.tsx index 4ea5d72..859b5b4 100644 --- a/web/src/composants/Resultat.tsx +++ b/web/src/composants/Resultat.tsx @@ -5,6 +5,7 @@ import { mouvementReduit } from '../services/animation' import type { BriqueJeu, CatalogueJeu, Prevision, TrompeJeu } from '../services/jeu' import { texteDuMot } from '../services/jeu' import { PetiteBrique } from './Brique' +import { Icone } from './Icone' const RAISONS: Record = { archaique: 'le mot est archaïque', @@ -14,9 +15,9 @@ const RAISONS: Record = { export type Retour = | { etat: 'prevision'; prevision: Prevision } | { etat: 'confirme'; issue: IssueFusion; /** Jalons atteints par une découverte, récompense à récupérer (ADR 0038, 0042). */ jalons?: string[] } - | { etat: 'message'; texte: React.ReactNode; ton?: 'hint' | 'fail' } + | { etat: 'message'; texte: React.ReactNode; ton?: 'hint' | 'fail' | 'reseau' } -type Ton = 'win' | 'hint' | 'fail' | 'known' | 'wait' +type Ton = 'win' | 'hint' | 'fail' | 'known' | 'wait' | 'reseau' const NOMBRES = ['', 'Une', 'Deux', 'Trois'] @@ -40,6 +41,10 @@ interface Props { onCarte: (uniteId: string) => void /** Retire la brique de ce rang et ouvre la réserve sur sa forme. */ onRemplacer: (rang: number, forme: string) => void + /** Palier d'attente de la réponse (ADR 0085) : 0 tout de suite, 1 après 1 s, 2 après 4 s. */ + attente?: 0 | 1 | 2 + /** Refait la fusion après une perte du réseau, sous la même clé de commande. */ + onReessayer?: () => void } function Ligne({ ton, children }: { ton?: Ton; children: React.ReactNode }) { @@ -56,15 +61,32 @@ function Ligne({ ton, children }: { ton?: Ton; children: React.ReactNode }) { * « presque » propose un geste. Un mot trouvé, nouveau ou connu, ouvre sa carte : * ici ne reste qu'une ligne. */ -export function Resultat({ retour, jeu, posees, bloquee, onInverser, aRecuperer, onQuetes, briquesPosees, decouverts, onCarte, onRemplacer }: Props) { +export function Resultat({ retour, jeu, posees, bloquee, onInverser, aRecuperer, onQuetes, briquesPosees, decouverts, onCarte, onRemplacer, attente = 0, onReessayer }: Props) { let contenu: React.ReactNode // Ce que dit le retour : quand elle change, l'ancien message sort et le nouveau entre. - const cle = retour?.etat === 'confirme' ? `confirme:${retour.issue.type}${retour.issue.type === 'echec' && retour.issue.explication ? `:${retour.issue.explication.nature}` : ''}` : retour?.etat === 'message' ? `message:${typeof retour.texte === 'string' ? retour.texte : ''}` : (retour?.etat ?? `attente:${bloquee && posees > 0 ? 'bloquee' : posees}`) - if (retour?.etat === 'message') { + const cle = retour?.etat === 'confirme' ? `confirme:${retour.issue.type}${retour.issue.type === 'echec' && retour.issue.explication ? `:${retour.issue.explication.nature}` : ''}` : retour?.etat === 'message' ? `message:${typeof retour.texte === 'string' ? retour.texte : ''}` : retour?.etat === 'prevision' ? `prevision:${attente === 2 ? 'lente' : ''}` : `attente:${bloquee && posees > 0 ? 'bloquee' : posees}` + if (retour?.etat === 'message' && retour.ton === 'reseau') { + // Erreur de réseau (ADR 0085) : ni secousse ni teinte d'échec, la table reste et le geste se refait. + contenu = ( +
+ + + {retour.texte} + + {onReessayer && ( +
+ +
+ )} +
+ ) + } else if (retour?.etat === 'message') { contenu = {retour.texte} } else if (retour?.etat === 'prevision') { // Aucune découverte n'est annoncée avant la validation du serveur (interfaces, règle 05). - contenu = Vérification de votre hypothèse… + contenu = {attente === 2 ? 'Le réseau est lent, nous insistons…' : 'Vérification de votre hypothèse…'} } else if (retour?.etat === 'confirme') { const issue = retour.issue switch (issue.type) { diff --git a/web/src/composants/Table.tsx b/web/src/composants/Table.tsx index 9994415..b48188e 100644 --- a/web/src/composants/Table.tsx +++ b/web/src/composants/Table.tsx @@ -13,6 +13,8 @@ interface Props { table: (string | null)[] briques: Map enAttente: boolean + /** Palier d'attente de la réponse (ADR 0085) : à partir de 1, les briques se rapprochent sans s'emboîter. */ + attente?: 0 | 1 | 2 onPoser: (brique: string, rang: number) => void onDeplacer: (depuis: number, vers: number) => void onRetirer: (rang: number) => void @@ -70,7 +72,7 @@ function Eclat({ mot, natures }: FusionEnCours) { * par « + », car l'ordre compte. On y glisse une brique ; toucher une case vide ouvre * la réserve, toucher une case pleine la vide ; glisser une brique posée sur une autre case les échange. */ -export function Table({ table, briques, enAttente, onPoser, onDeplacer, onRetirer, onCaseVide, secousse, fusion }: Props) { +export function Table({ table, briques, enAttente, attente = 0, onPoser, onDeplacer, onRetirer, onCaseVide, secousse, fusion }: Props) { const [survol, setSurvol] = useState(null) // Secousse déjà jouée : sans cela, elle rejouerait à chaque retour sur la table (display: none relance l'animation). const [secouee, setSecouee] = useState(null) @@ -105,7 +107,7 @@ export function Table({ table, briques, enAttente, onPoser, onDeplacer, onRetire
0 && !fusion ? 'attente' : ''].filter(Boolean).join(' ')} onAnimationEnd={(e) => e.target === e.currentTarget && e.animationName.startsWith('amorti') && setSecouee(secousse?.cle ?? null)} role="group" aria-label="Briques posées, dans l’ordre" diff --git a/web/src/main.tsx b/web/src/main.tsx index 9b24b9f..402b124 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -11,13 +11,20 @@ import { MiseAJour } from './composants/MiseAJour' import { config } from './config' import { ClientApi } from './services/api' import { LecteurCatalogue } from './services/catalogue' +import { demanderStockagePersistant } from './services/prechargement' +import { reseau } from './services/reseau' const racine = document.getElementById('root') if (!racine) throw new Error('Élément #root introuvable') +const api = new ClientApi(config.urlApi) +// La sonde du retour du réseau : un seul essai sur `/sante` (ADR 0085). +reseau.configurerSonde(() => api.sonder()) +demanderStockagePersistant() + createRoot(racine).render( - + , ) diff --git a/web/src/services/api.test.ts b/web/src/services/api.test.ts index 7a24826..5995476 100644 --- a/web/src/services/api.test.ts +++ b/web/src/services/api.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { ClientApi, ErreurApi } from './api' +import { Reseau } from './reseau' const PROFIL = { installation: '6f9619ff-8b86-4d11-b42d-00c04fc964ff', secret: 'a'.repeat(43) } @@ -27,7 +28,7 @@ describe('client de l’API', () => { it('transforme une panne réseau en erreur explicite, jamais en succès', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) - const client = new ClientApi('https://api.exemple.test', () => Promise.resolve(PROFIL)) + const client = new ClientApi('https://api.exemple.test', () => Promise.resolve(PROFIL), { delais: [], reseau: new Reseau() }) await expect(client.etat()).rejects.toMatchObject({ code: 'reseau' }) }) @@ -38,4 +39,78 @@ describe('client de l’API', () => { expect(erreur).toBeInstanceOf(ErreurApi) expect(erreur).toMatchObject({ statut: 429, code: 'trop_de_requetes' }) }) + + describe('réseau instable (ADR 0085)', () => { + const expire = () => new Date(Date.now() + 15 * 60_000).toISOString() + const rapide = (reseau = new Reseau()) => ({ delais: [1, 1, 1], delaiEssaiMs: 50, reseau }) + const demande = { commande: '6f9619ff-8b86-4d11-b42d-00c04fc964ff', version_etat: 1, version_catalogue: 'v1', briques: ['a', 'b'] } + + it('renvoie la même commande, même corps donc même clé, après une coupure', async () => { + const appels = vi + .fn() + .mockResolvedValueOnce(json({ jeton: 'j1', expire_le: expire(), nouveau: false })) + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValueOnce(json({ code: 'interne', message: 'x' }, 502)) + .mockResolvedValueOnce(json({ issue: { type: 'echec' } })) + vi.stubGlobal('fetch', appels) + const reseau = new Reseau() + const client = new ClientApi('https://api.exemple.test', () => Promise.resolve(PROFIL), rapide(reseau)) + expect(await client.fusion(demande)).toEqual({ issue: { type: 'echec' } }) + const corps = appels.mock.calls.slice(1).map(([, init]) => (init as RequestInit).body) + expect(corps).toHaveLength(3) + expect(new Set(corps).size).toBe(1) + expect(reseau.lire().niveau).toBe('instable') + }) + + it('abandonne un essai trop long, puis le renvoie', async () => { + const appels = vi + .fn() + .mockImplementationOnce((_url: string, init: RequestInit) => new Promise((_, rejeter) => init.signal?.addEventListener('abort', () => rejeter(new DOMException('abort', 'AbortError'))))) + .mockResolvedValueOnce(json({ statut: 'ok' })) + vi.stubGlobal('fetch', appels) + const client = new ClientApi('https://api.exemple.test', () => Promise.resolve(PROFIL), rapide()) + expect(await client.sante()).toEqual({ statut: 'ok' }) + expect(appels).toHaveBeenCalledTimes(2) + }) + + it('ne renvoie jamais une réponse du serveur : 409, 403, ni un 503 de l’API', async () => { + for (const [statut, code] of [[409, 'conflit'], [403, 'interdit'], [503, 'indisponible']] as const) { + const appels = vi.fn().mockResolvedValue(json({ code, message: 'x' }, statut)) + vi.stubGlobal('fetch', appels) + const client = new ClientApi('https://api.exemple.test', () => Promise.resolve(PROFIL), rapide()) + await expect(client.sante()).rejects.toMatchObject({ statut, code }) + expect(appels).toHaveBeenCalledTimes(1) + } + }) + + it('renvoie un 429 seulement si l’attente demandée est courte', async () => { + const appels = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ code: 'trop_de_requetes', message: 'x' }), { status: 429, headers: { 'Retry-After': '0' } })) + .mockResolvedValueOnce(json({ statut: 'ok' })) + vi.stubGlobal('fetch', appels) + const client = new ClientApi('https://api.exemple.test', () => Promise.resolve(PROFIL), rapide()) + expect(await client.sante()).toEqual({ statut: 'ok' }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ code: 'trop_de_requetes', message: 'x' }), { status: 429, headers: { 'Retry-After': '60' } }))) + await expect(client.sante()).rejects.toMatchObject({ statut: 429 }) + }) + + it('met le réseau hors ligne quand les essais sont épuisés, mais pas pour une sonde', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + const reseau = new Reseau() + const client = new ClientApi('https://api.exemple.test', () => Promise.resolve(PROFIL), rapide(reseau)) + await expect(client.sonder()).rejects.toMatchObject({ code: 'reseau' }) + expect(reseau.lire().niveau).toBe('instable') + await expect(client.sante()).rejects.toMatchObject({ code: 'reseau' }) + expect(reseau.lire().niveau).toBe('hors_ligne') + }) + + it('n’ouvre qu’une session pour deux appels simultanés', async () => { + const appels = vi.fn((url: string) => Promise.resolve(url.endsWith('/session') ? json({ jeton: 'j1', expire_le: expire(), nouveau: false }) : json({ version_etat: 0 }))) + vi.stubGlobal('fetch', appels) + const client = new ClientApi('https://api.exemple.test', () => Promise.resolve(PROFIL), rapide()) + await Promise.all([client.etat(), client.etat()]) + expect(appels.mock.calls.filter(([url]) => url.endsWith('/session'))).toHaveLength(1) + }) + }) }) diff --git a/web/src/services/api.ts b/web/src/services/api.ts index e7b9f18..3d82e16 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -3,6 +3,7 @@ import type { DemandeAchatSablier, DemandeFusion, DemandeIndice, DemandeJalon, DemandePalier, DemandePli, DemandeRicochet, DemandeSablier, Erreur, Etat, ReponseAchatSablier, ReponseFusion, ReponseIndice, ReponseJalon, ReponsePalier, ReponsePli, ReponseRicochet, ReponseSablier, Sante, SeanceRicochets, Session } from '../contrat' import { profilLocal } from './profil' +import { REPONSE_LENTE_MS, type Reseau, reseau as reseauGlobal } from './reseau' export class ErreurApi extends Error { readonly statut: number @@ -18,45 +19,170 @@ export class ErreurApi extends Error { /** Marge avant l'expiration du jeton à partir de laquelle on le renouvelle. */ const MARGE_MS = 60_000 +/** Un essai qui dépasse ce délai est abandonné, puis renvoyé (ADR 0085). */ +export const DELAI_ESSAI_MS = 8_000 +/** Attente avant chaque nouvel essai, avec une part de hasard : 0,5 s, 1 s, 2 s, 4 s. */ +export const DELAIS_NOUVEL_ESSAI_MS = [500, 1_000, 2_000, 4_000] +/** Au-delà de cette durée depuis le premier essai, on n'en tente plus. */ +const FENETRE_MS = 15_000 +/** Un `429` n'est renvoyé que si le serveur demande d'attendre au plus ce délai. */ +const ATTENTE_429_MAX_MS = 5_000 + +export interface OptionsClient { + /** État du réseau à tenir informé des essais. */ + reseau?: Reseau + /** Attentes entre les essais (tests : `[]` pour n'en faire qu'un). */ + delais?: number[] + delaiEssaiMs?: number +} + +const attendre = (ms: number) => new Promise((fin) => setTimeout(fin, ms)) + +/** Signal qui coupe un essai trop long, corps de la réponse compris. */ +function signalDelai(ms: number): { signal: AbortSignal; liberer: () => void } { + const controleur = new AbortController() + const minuteur = setTimeout(() => controleur.abort(), ms) + return { signal: controleur.signal, liberer: () => clearTimeout(minuteur) } +} + +/** Délai demandé par un `Retry-After` en secondes, s'il est lisible. */ +function retryAfter(reponse: Response): number | null { + const entete = reponse.headers.get('Retry-After') + const valeur = entete === null || entete.trim() === '' ? NaN : Number(entete) + return Number.isFinite(valeur) && valeur >= 0 ? valeur * 1000 : null +} + export class ClientApi { private session: Session | undefined + private ouverture: Promise | undefined + private renouvellement: ReturnType | undefined private readonly base: string private readonly lireProfil: typeof profilLocal + private readonly reseau: Reseau + private readonly delais: number[] + private readonly delaiEssaiMs: number - constructor(base: string, lireProfil = profilLocal) { + constructor(base: string, lireProfil = profilLocal, options: OptionsClient = {}) { this.base = base this.lireProfil = lireProfil + this.reseau = options.reseau ?? reseauGlobal + this.delais = options.delais ?? DELAIS_NOUVEL_ESSAI_MS + this.delaiEssaiMs = options.delaiEssaiMs ?? DELAI_ESSAI_MS } - private async appeler(chemin: string, init: RequestInit = {}): Promise { - let reponse: Response + /** + * Un essai : délai borné, corps lu sous le même délai. Renvoie l'erreur à + * retenter (`nouvelEssai`) ou la réponse définitive du serveur. + */ + private async essayer(chemin: string, init: RequestInit): Promise<{ ok: T } | { erreur: ErreurApi; nouvelEssai: number | null }> { + const { signal, liberer } = signalDelai(this.delaiEssaiMs) + const debut = Date.now() + // Une réponse qui tarde rend le réseau instable sans attendre qu'elle arrive. + const lenteur = setTimeout(() => this.reseau.noterLenteur(), REPONSE_LENTE_MS) try { - reponse = await fetch(`${this.base}${chemin}`, { ...init, credentials: 'omit', cache: 'no-store' }) - } catch { - throw new ErreurApi(0, 'reseau', 'API injoignable') + let reponse: Response + try { + reponse = await fetch(`${this.base}${chemin}`, { ...init, credentials: 'omit', cache: 'no-store', signal }) + } catch { + this.reseau.noterEchec() + return { erreur: new ErreurApi(0, 'reseau', 'API injoignable'), nouvelEssai: 0 } + } + let lu = true + const corps: unknown = await reponse.json().catch(() => { + lu = false + return undefined + }) + // Corps coupé en route : le serveur a peut-être répondu, la même commande le dira. + if (!lu && signal.aborted) { + this.reseau.noterEchec() + return { erreur: new ErreurApi(0, 'reseau', 'Réponse interrompue'), nouvelEssai: 0 } + } + const erreur = reponse.ok ? undefined : (corps as Partial | undefined) + // Passerelle en défaut (502, 504, ou 503 qui ne vient pas de l'API) : le réseau, pas le serveur. + if (reponse.status === 502 || reponse.status === 504 || (reponse.status === 503 && !erreur?.code)) { + this.reseau.noterEchec() + return { erreur: new ErreurApi(reponse.status, 'reseau', reponse.statusText || 'Passerelle indisponible'), nouvelEssai: 0 } + } + this.reseau.noterReussite(Date.now() - debut) + if (!reponse.ok) { + const e = new ErreurApi(reponse.status, erreur?.code ?? 'interne', erreur?.message ?? reponse.statusText) + const attente = reponse.status === 429 ? retryAfter(reponse) : null + return { erreur: e, nouvelEssai: attente !== null && attente <= ATTENTE_429_MAX_MS ? attente : null } + } + return { ok: corps as T } + } finally { + liberer() + clearTimeout(lenteur) } - const corps: unknown = await reponse.json().catch(() => undefined) - if (!reponse.ok) { - const erreur = corps as Partial | undefined - throw new ErreurApi(reponse.status, erreur?.code ?? 'interne', erreur?.message ?? reponse.statusText) + } + + /** + * Envoie la requête, et la renvoie à l'identique, même corps donc même clé + * de commande, tant que le réseau seul est en cause (ADR 0085). Le serveur + * rejoue une commande déjà traitée : aucun double effet. + */ + private async appeler(chemin: string, init: RequestInit = {}, retenter = true): Promise { + const debut = Date.now() + for (let essai = 0; ; essai++) { + const resultat = await this.essayer(chemin, init) + if ('ok' in resultat) return resultat.ok + const { erreur, nouvelEssai } = resultat + const pause = retenter ? this.delais[essai] : undefined + if (nouvelEssai === null) throw erreur + if (pause === undefined || Date.now() - debut > FENETRE_MS) { + if (erreur.code === 'reseau' && retenter) this.reseau.noterPerte() + throw erreur + } + // Part de hasard : deux onglets ou deux joueurs ne repartent pas ensemble. + await attendre(Math.max(nouvelEssai, pause * (0.75 + Math.random() * 0.5))) } - return corps as T } sante(): Promise { return this.appeler('/sante') } - /** Ouvre la session si besoin, ou la renouvelle avant son expiration. */ - async ouvrirSession(): Promise { - if (this.session && Date.parse(this.session.expire_le) - Date.now() > MARGE_MS) return this.session - const profil = await this.lireProfil() - this.session = await this.appeler('/session', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(profil), - }) - return this.session + /** Sonde du retour du réseau : un seul essai, sans rien signaler comme perdu. */ + sonder(): Promise { + return this.appeler('/sante', {}, false) + } + + /** Ouvre la session si besoin, ou la renouvelle avant son expiration. Une seule ouverture à la fois. */ + async ouvrirSession(forcer = false): Promise { + if (!forcer && this.session && Date.parse(this.session.expire_le) - Date.now() > MARGE_MS) return this.session + this.ouverture ??= (async () => { + try { + const profil = await this.lireProfil() + const session = await this.appeler('/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(profil), + }) + this.session = session + this.planifierRenouvellement(session) + return session + } finally { + this.ouverture = undefined + } + })() + return this.ouverture + } + + /** + * Renouvelle le jeton en arrière-plan, avant qu'il n'expire : une commande + * sur un réseau lent n'attend pas deux allers-retours. Page cachée, on attend + * son retour (une commande renouvellera au besoin). + */ + private planifierRenouvellement(session: Session): void { + clearTimeout(this.renouvellement) + if (typeof document === 'undefined') return + const dans = Date.parse(session.expire_le) - Date.now() - MARGE_MS - 5_000 + if (!(dans > 0)) return + this.renouvellement = setTimeout(() => { + if (document.visibilityState === 'hidden') return + // L'ancien jeton reste valable jusqu'à la réponse : un échec ne coûte rien. + this.ouvrirSession(true).catch(() => undefined) + }, dans) } /** Appel authentifié ; une session expirée est rouverte une fois. */ diff --git a/web/src/services/attente.ts b/web/src/services/attente.ts new file mode 100644 index 0000000..3067a0a --- /dev/null +++ b/web/src/services/attente.ts @@ -0,0 +1,25 @@ +// Paliers d'attente d'une réponse du serveur (ADR 0085) : rien de plus pendant +// la première seconde, un signe discret ensuite, un mot après 4 s. + +import { useEffect, useState } from 'react' + +export const PALIER_1_MS = 1_000 +export const PALIER_2_MS = 4_000 + +export type PalierAttente = 0 | 1 | 2 + +/** 0 pendant la première seconde d'attente, 1 ensuite, 2 après 4 s ; 0 dès que l'attente cesse. */ +export function useAttente(actif: boolean): PalierAttente { + const [palier, setPalier] = useState(0) + useEffect(() => { + if (!actif) return + const premier = setTimeout(() => setPalier(1), PALIER_1_MS) + const second = setTimeout(() => setPalier(2), PALIER_2_MS) + return () => { + clearTimeout(premier) + clearTimeout(second) + setPalier(0) + } + }, [actif]) + return actif ? palier : 0 +} diff --git a/web/src/services/horloge.ts b/web/src/services/horloge.ts index f90f996..f72571d 100644 --- a/web/src/services/horloge.ts +++ b/web/src/services/horloge.ts @@ -9,3 +9,13 @@ export function useMaintenant(): number { }, []) return maintenant } + +/** « il y a 4 min » : depuis quand la partie est à jour. */ +export function depuis(instant: number, maintenant: number): string { + const minutes = Math.floor((maintenant - instant) / 60_000) + if (minutes < 1) return 'à l’instant' + if (minutes < 60) return `il y a ${minutes} min` + const heures = Math.floor(minutes / 60) + if (heures < 24) return `il y a ${heures} h` + return `il y a ${Math.floor(heures / 24)} j` +} diff --git a/web/src/services/local.ts b/web/src/services/local.ts index e2f9e0e..c313e22 100644 --- a/web/src/services/local.ts +++ b/web/src/services/local.ts @@ -43,8 +43,16 @@ export async function etatConfirme(): Promise { return completerEtat(etat as Etat) } -export function memoriserEtat(etat: Etat): Promise { - return set('etat', etat, stockage()) +/** Garde l'état confirmé et l'heure de la réponse du serveur qui l'a donné. */ +export async function memoriserEtat(etat: Etat, synchroniseLe = Date.now()): Promise { + await set('etat', etat, stockage()) + await set('synchronise_le', synchroniseLe, stockage()) +} + +/** Heure (ms) de la dernière réponse du serveur gardée en local, `null` si inconnue. */ +export async function synchroniseLocal(): Promise { + const le: unknown = await get('synchronise_le', stockage()) + return typeof le === 'number' && Number.isFinite(le) ? le : null } /** Cases de la table en cours, chacune à sa place (`null` pour une case vide). */ diff --git a/web/src/services/partie.ts b/web/src/services/partie.ts index 674a755..e1e344d 100644 --- a/web/src/services/partie.ts +++ b/web/src/services/partie.ts @@ -5,7 +5,7 @@ import type { Retour } from '../composants/Resultat' import { type ClientApi, ErreurApi } from './api' import type { LecteurCatalogue } from './catalogue' import { type BriqueJeu, type CatalogueJeu, TABLE_MAX, chargerJeu, prevoir, texteDuMot } from './jeu' -import { completerEtat, etatConfirme, memoriserEtat, memoriserTable, tableEnCours } from './local' +import { completerEtat, etatConfirme, memoriserEtat, memoriserTable, synchroniseLocal, tableEnCours } from './local' import { nombrePourRemplir } from './plis' type Chargement = { etat: 'attente' } | { etat: 'pret'; valeur: T } | { etat: 'erreur'; message: string } @@ -22,8 +22,16 @@ export interface FicheOuverte { motId: string } +/** Erreur de réseau : la commande n'a pas abouti, aucune réponse du serveur ne dit le contraire. */ +function erreurReseau(e: unknown): boolean { + return e instanceof ErreurApi && e.code === 'reseau' +} + +/** Erreur de réseau après tous les essais (ADR 0085) : rien n'est perdu, le même geste peut se refaire. */ +export const PERTE_RESEAU = 'La connexion s’est perdue. Rien n’est perdu : réessayez.' + function message(e: unknown): string { - if (e instanceof ErreurApi && e.code === 'reseau') return 'Le serveur est injoignable pour le moment.' + if (erreurReseau(e)) return PERTE_RESEAU if (e instanceof ErreurApi && e.code === 'trop_de_requetes') return 'Trop de tentatives : réessayez dans une minute.' return 'Une erreur est survenue. Réessayez plus tard.' } @@ -54,7 +62,10 @@ function tableValide(cases: Cases, etat: Etat): Cases { }) } -const HORS_LIGNE = 'Hors ligne : consultation seulement. Reconnectez-vous pour jouer.' +const HORS_LIGNE = 'Hors ligne : cette action attend le réseau.' + +/** Au retour au premier plan, l'état se resynchronise s'il date de plus que cela. */ +const RESYNC_PREMIER_PLAN_MS = 30_000 /** État d'une partie : catalogue, état confirmé, table et retours de fusion. */ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = true) { @@ -77,8 +88,33 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t const [nouvelles, setNouvelles] = useState>(new Set()) /** Vrai hors production seulement : le serveur ouvre alors la réinitialisation. */ const [reinitialisable, setReinitialisable] = useState(false) + /** Heure de la dernière réponse du serveur, gardée en local : « partie à jour il y a 4 min ». */ + const [synchroniseLe, setSynchroniseLe] = useState(null) const actif = useRef(true) const restauree = useRef(false) + /** Resynchronisation en cours : une seule à la fois. */ + const synchro = useRef | null>(null) + /** + * Clé de commande de chaque geste (ADR 0085) : tirée une fois, réutilisée par les + * nouveaux essais et par « Réessayer » tant que le serveur n'a pas répondu. + */ + const cles = useRef(new Map()) + const avecCle = useCallback(async (geste: string, envoyer: (commande: string) => Promise): Promise => { + let commande = cles.current.get(geste) + if (!commande) { + commande = crypto.randomUUID() + cles.current.set(geste, commande) + } + try { + const reponse = await envoyer(commande) + cles.current.delete(geste) + return reponse + } catch (e) { + // Le serveur a répondu (même un refus) : le prochain geste aura sa propre clé. + if (!erreurReseau(e)) cles.current.delete(geste) + throw e + } + }, []) /** Dernier état confirmé, pour enchaîner les réponses d'une séance sans attendre un rendu. */ const etatCourant = useRef(null) @@ -90,26 +126,37 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t etatCourant.current = nouvel setEtat(nouvel) setTable((t) => tableValide(t, nouvel)) - void memoriserEtat(nouvel) + const le = Date.now() + setSynchroniseLe(le) + void memoriserEtat(nouvel, le) }, []) - const synchroniser = useCallback(async () => { - const [catalogue, confirme] = await Promise.allSettled([chargerJeu(lecteur), api.etat()]) - if (!actif.current) return - if (catalogue.status === 'fulfilled') setJeu({ etat: 'pret', valeur: catalogue.value }) - else setJeu({ etat: 'erreur', message: 'Le catalogue est illisible pour le moment.' }) - if (confirme.status === 'fulfilled') { - setErreurEtat(null) - confirmer(confirme.value) - } else setErreurEtat(message(confirme.reason)) + const synchroniser = useCallback(() => { + synchro.current ??= (async () => { + try { + const [catalogue, confirme] = await Promise.allSettled([chargerJeu(lecteur), api.etat()]) + if (!actif.current) return + if (catalogue.status === 'fulfilled') setJeu({ etat: 'pret', valeur: catalogue.value }) + // Un catalogue déjà lu reste valable : une resynchronisation ratée ne l'efface pas. + else setJeu((j) => (j.etat === 'pret' ? j : { etat: 'erreur', message: 'Le catalogue est illisible pour le moment.' })) + if (confirme.status === 'fulfilled') { + setErreurEtat(null) + confirmer(confirme.value) + } else setErreurEtat(message(confirme.reason)) + } finally { + synchro.current = null + } + })() + return synchro.current }, [api, lecteur, confirmer]) useEffect(() => { actif.current = true // La dernière projection confirmée s'affiche tout de suite, puis le serveur la remplace. - void Promise.all([etatConfirme(), tableEnCours()]).then(([local, t]) => { + void Promise.all([etatConfirme(), tableEnCours(), synchroniseLocal()]).then(([local, t, le]) => { restauree.current = true if (!actif.current) return + setSynchroniseLe((actuel) => actuel ?? le) if (local) { setEtat((e) => e ?? local) setTable((actuelle) => (actuelle.some(Boolean) ? actuelle : tableValide(t, local))) @@ -122,6 +169,24 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t } }, [synchroniser]) + // Retour du réseau (ADR 0085) : l'état se resynchronise de lui-même. + const enLignePrecedent = useRef(enLigne) + useEffect(() => { + const revenu = enLigne && !enLignePrecedent.current + enLignePrecedent.current = enLigne + if (revenu) void synchroniser() + }, [enLigne, synchroniser]) + + // Retour au premier plan : le téléphone a pu dormir, l'énergie et l'état ont pu changer. + useEffect(() => { + const auPremierPlan = () => { + if (document.visibilityState !== 'visible' || !enLigne) return + if (synchroniseLe === null || Date.now() - synchroniseLe > RESYNC_PREMIER_PLAN_MS) void synchroniser() + } + document.addEventListener('visibilitychange', auPremierPlan) + return () => document.removeEventListener('visibilitychange', auPremierPlan) + }, [enLigne, synchroniseLe, synchroniser]) + useEffect(() => { api.sante().then( (s) => actif.current && setReinitialisable(s.reinitialisation), @@ -135,7 +200,6 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t /** Pose une brique dans la première case libre ; table pleine, rien ne change. */ const ajouter = useCallback((brique: string) => { - if (!enLigne) return setTable((t) => { const libre = t.indexOf(null) if (libre < 0) return t @@ -145,11 +209,10 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t }) setRetour(null) setFiche(null) - }, [enLigne]) + }, []) /** Pose une brique dans la case `rang` : elle remplace celle qui s'y trouvait. */ const poser = useCallback((brique: string, rang: number) => { - if (!enLigne) return setTable((t) => { if (rang < 0 || rang >= t.length) return t const suite = [...t] @@ -158,11 +221,10 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t }) setRetour(null) setFiche(null) - }, [enLigne]) + }, []) /** Échange deux cases, pleines ou vides. */ const deplacer = useCallback((depuis: number, vers: number) => { - if (!enLigne) return setTable((t) => { if (vers < 0 || vers >= t.length || depuis === vers) return t const suite = [...t] @@ -171,14 +233,13 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t return suite }) setRetour(null) - }, [enLigne]) + }, []) /** Vide une case : l'autre brique garde sa place. */ const retirer = useCallback((rang: number) => { - if (!enLigne) return setTable((t) => t.map((b, i) => (i === rang ? null : b))) setRetour(null) - }, [enLigne]) + }, []) const fusionner = useCallback(async () => { if (!enLigne || jeu.etat !== 'pret' || !jeu.valeur || !etat) return @@ -192,12 +253,9 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t setSecousse(null) setEnAttente(true) try { - const reponse = await api.fusion({ - commande: crypto.randomUUID(), - version_etat: etat.version_etat, - version_catalogue: catalogue.version, - briques: proposition, - }) + const reponse = await avecCle(`fusion:${etat.version_etat}:${catalogue.version}:${proposition.join(',')}`, (commande) => + api.fusion({ commande, version_etat: etat.version_etat, version_catalogue: catalogue.version, briques: proposition }), + ) const issue = reponse.issue if (issue.type === 'decouverte') { // Les briques se rejoignent, puis le mot se révèle et les « −1 » s'envolent (jeu.html, 700 ms). @@ -229,13 +287,16 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t if (e instanceof ErreurApi && e.statut === 409) { await synchroniser() setRetour({ etat: 'message', texte: 'Votre partie a changé entre-temps. Elle est à jour : réessayez.' }) + } else if (erreurReseau(e)) { + // Jamais de secousse : ce n'est pas un échec de jeu. La table reste, « Réessayer » renvoie la même clé. + setRetour({ etat: 'message', texte: 'La connexion s’est perdue. Votre table est intacte, rien n’est perdu.', ton: 'reseau' }) } else { setRetour({ etat: 'message', texte: message(e) }) } } finally { setEnAttente(false) } - }, [api, enLigne, jeu, etat, cases, confirmer, synchroniser]) + }, [api, enLigne, jeu, etat, cases, confirmer, synchroniser, avecCle]) /** * Ouvre le pli d'un fascicule (ADR 0011) : le serveur tire la brique. Rien @@ -246,12 +307,10 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t if (!enLigne) return { erreur: HORS_LIGNE } if (jeu.etat !== 'pret' || !jeu.valeur || !etat) return { erreur: 'Votre partie n’est pas encore chargée.' } try { - const reponse = await api.pli({ - commande: crypto.randomUUID(), - version_etat: etat.version_etat, - version_catalogue: jeu.valeur.version, - fascicule_id: fascicule, - }) + const version = jeu.valeur.version + const reponse = await avecCle(`pli:${etat.version_etat}:${version}:${fascicule}`, (commande) => + api.pli({ commande, version_etat: etat.version_etat, version_catalogue: version, fascicule_id: fascicule }), + ) confirmer(reponse.etat) // Une brique nouvelle entre au codex : sa carte se signale en Corail jusqu'à ce qu'on l'ouvre. const unite = jeu.valeur.briques.get(reponse.ouverture.brique_id)?.uniteId @@ -269,7 +328,7 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t return { erreur: message(e) } } }, - [api, enLigne, jeu, etat, confirmer, synchroniser], + [api, enLigne, jeu, etat, confirmer, synchroniser, avecCle], ) /** @@ -277,11 +336,12 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t * resynchronise, et l'erreur n'est jamais un succès local. */ const commander = useCallback( - async (envoyer: (e: Etat, version: string) => Promise, refus: string): Promise => { + async (geste: string, envoyer: (e: Etat, version: string, commande: string) => Promise, refus: string): Promise => { if (!enLigne) return { erreur: HORS_LIGNE } if (jeu.etat !== 'pret' || !jeu.valeur || !etat) return { erreur: 'Votre partie n’est pas encore chargée.' } + const version = jeu.valeur.version try { - const reponse = await envoyer(etat, jeu.valeur.version) + const reponse = await avecCle(`${geste}:${etat.version_etat}:${version}`, (commande) => envoyer(etat, version, commande)) confirmer(reponse.etat) return reponse } catch (e) { @@ -292,13 +352,14 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t return { erreur: message(e) } } }, - [enLigne, jeu, etat, confirmer, synchroniser], + [enLigne, jeu, etat, confirmer, synchroniser, avecCle], ) const utiliserSabliers = useCallback( (nombre: number): Promise => commander( - (e, version) => api.sablier({ commande: crypto.randomUUID(), version_etat: e.version_etat, version_catalogue: version, nombre }), + `sablier:${nombre}`, + (e, version, commande) => api.sablier({ commande, version_etat: e.version_etat, version_catalogue: version, nombre }), 'Ces sabliers ne peuvent pas servir maintenant.', ), [api, commander], @@ -307,7 +368,8 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t const acheterSabliers = useCallback( (nombre: number): Promise => commander( - (e, version) => api.achatSablier({ commande: crypto.randomUUID(), version_etat: e.version_etat, version_catalogue: version, nombre }), + `achat:${nombre}`, + (e, version, commande) => api.achatSablier({ commande, version_etat: e.version_etat, version_catalogue: version, nombre }), 'Pas assez d’encre pour cet échange.', ), [api, commander], @@ -325,7 +387,10 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t const utilisables = Math.min(achat.etat.sabliers?.detenus ?? 0, nombrePourRemplir(achat.etat.energie, Date.now())) if (utilisables < 1) return achat try { - const reponse = await api.sablier({ commande: crypto.randomUUID(), version_etat: achat.etat.version_etat, version_catalogue: jeu.valeur.version, nombre: utilisables }) + const version = jeu.valeur.version + const reponse = await avecCle(`sablier:${utilisables}:${achat.etat.version_etat}:${version}`, (commande) => + api.sablier({ commande, version_etat: achat.etat.version_etat, version_catalogue: version, nombre: utilisables }), + ) confirmer(reponse.etat) return reponse } catch (e) { @@ -333,13 +398,14 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t return { erreur: 'Les sabliers sont échangés, mais le temps n’a pas avancé : utilisez-les avec « Avancer ».' } } }, - [api, jeu, acheterSabliers, confirmer, synchroniser], + [api, jeu, acheterSabliers, confirmer, synchroniser, avecCle], ) const acheterIndice = useCallback( (recette: string, niveau: number): Promise => commander( - (e, version) => api.indice({ commande: crypto.randomUUID(), version_etat: e.version_etat, version_catalogue: version, recette_id: recette, niveau }), + `indice:${recette}:${niveau}`, + (e, version, commande) => api.indice({ commande, version_etat: e.version_etat, version_catalogue: version, recette_id: recette, niveau }), 'Pas assez d’encre pour cet indice.', ), [api, commander], @@ -353,7 +419,8 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t async (jalon: string): Promise => { const connues = new Set(etat?.reserve.map((e) => e.brique_id)) const reponse = await commander( - (e, version) => api.jalon({ commande: crypto.randomUUID(), version_etat: e.version_etat, version_catalogue: version, jalon_id: jalon }), + `jalon:${jalon}`, + (e, version, commande) => api.jalon({ commande, version_etat: e.version_etat, version_catalogue: version, jalon_id: jalon }), 'Cette récompense n’est pas encore disponible.', ) if (!('erreur' in reponse) && jeu.etat === 'pret' && jeu.valeur) { @@ -393,7 +460,9 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t const e = etatCourant.current if (!e) break try { - const recu = await api.ricochet({ commande: crypto.randomUUID(), version_etat: e.version_etat, version_catalogue: version, format, unite_id: unite, reponse }) + const recu = await avecCle(`ricochet:${e.version_etat}:${version}:${format}:${unite}:${JSON.stringify(reponse)}`, (commande) => + api.ricochet({ commande, version_etat: e.version_etat, version_catalogue: version, format, unite_id: unite, reponse }), + ) confirmer(recu.etat) return recu } catch (err) { @@ -410,14 +479,15 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t fileRicochets.current = suite return suite }, - [api, enLigne, jeu, confirmer, synchroniser], + [api, enLigne, jeu, confirmer, synchroniser, avecCle], ) /** Récupère la récompense d'un palier de quête au long cours (ADR 0045) : de l'encre et des sabliers. */ const recupererPalier = useCallback( (palier: string): Promise => commander( - (e, version) => api.palier({ commande: crypto.randomUUID(), version_etat: e.version_etat, version_catalogue: version, palier_id: palier }), + `palier:${palier}`, + (e, version, commande) => api.palier({ commande, version_etat: e.version_etat, version_catalogue: version, palier_id: palier }), 'Cette récompense n’est pas encore disponible.', ), [api, commander], @@ -451,6 +521,7 @@ export function usePartie(api: ClientApi, lecteur: LecteurCatalogue, enLigne = t repondreRicochet, recupererPalier, erreurEtat, + synchroniseLe, table, cases, retour, diff --git a/web/src/services/prechargement.test.ts b/web/src/services/prechargement.test.ts new file mode 100644 index 0000000..6f8540f --- /dev/null +++ b/web/src/services/prechargement.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' + +import { LecteurCatalogue } from './catalogue' +import type { CatalogueJeu } from './jeu' +import { mediasDuCatalogue, prechargerMedias } from './prechargement' +import { Reseau } from './reseau' + +const h = (c: string) => c.repeat(64) +const jeu = { + fascicules: [ + { manifeste: { jaquette: `a/${h('1')}.svg`, cartes: [{ silhouette: `a/${h('2')}.svg` }, { silhouette: null }], langues: [{ silhouette: `a/${h('2')}.svg` }] } }, + { manifeste: { jaquette: null, cartes: [{ silhouette: `a/${h('3')}.svg` }] } }, + ], +} as unknown as CatalogueJeu + +describe('préchargement du catalogue (ADR 0085)', () => { + it('liste chaque média une fois : jaquettes et silhouettes', () => { + expect(mediasDuCatalogue(jeu).sort()).toEqual([`a/${h('1')}.svg`, `a/${h('2')}.svg`, `a/${h('3')}.svg`]) + }) + + it('charge les médias en ligne, et rien sur un réseau instable', async () => { + const appels = vi.fn().mockImplementation(() => Promise.resolve(new Response(''))) + vi.stubGlobal('fetch', appels) + const lecteur = new LecteurCatalogue('https://catalogue.exemple.test/public') + expect(await prechargerMedias(jeu, lecteur, new Reseau(true))).toBe(3) + const instable = new Reseau(true) + instable.noterEchec() + appels.mockClear() + expect(await prechargerMedias(jeu, lecteur, instable)).toBe(0) + expect(appels).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/services/prechargement.ts b/web/src/services/prechargement.ts new file mode 100644 index 0000000..8db35b8 --- /dev/null +++ b/web/src/services/prechargement.ts @@ -0,0 +1,78 @@ +// Préchargement du catalogue (ADR 0085) : pendant que le réseau est bon, les +// médias des fascicules parus (silhouettes, jaquettes) passent par le service +// worker, qui les garde (`catalogue-hache`, CacheFirst). Dans un tunnel, le +// codex reste alors complet. Rien n'est vérifié ni décodé ici : la lecture +// réelle vérifie toujours l'empreinte (`LecteurCatalogue.fichierHache`). + +import type { LecteurCatalogue } from './catalogue' +import type { CatalogueJeu } from './jeu' +import { type Reseau, reseau as reseauGlobal } from './reseau' + +/** Requêtes de préchargement simultanées : le jeu garde la priorité. */ +const EN_PARALLELE = 3 + +/** Chemins des médias à garder hors connexion : silhouettes des cartes et jaquettes des fascicules. */ +export function mediasDuCatalogue(jeu: CatalogueJeu): string[] { + const chemins = new Set() + for (const { manifeste } of jeu.fascicules) { + if (manifeste.jaquette) chemins.add(manifeste.jaquette) + for (const c of [...(manifeste.cartes ?? []), ...(manifeste.langues ?? [])]) if (c.silhouette) chemins.add(c.silhouette) + } + return [...chemins] +} + +/** Vrai si le joueur a demandé d'économiser ses données : on ne précharge rien. */ +function economieDeDonnees(): boolean { + const connexion = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection + return connexion?.saveData === true +} + +/** + * Charge les médias un à un, trois à la fois, et s'arrête dès que le réseau + * n'est plus franchement en ligne. Renvoie le nombre de médias chargés. + */ +export async function prechargerMedias(jeu: CatalogueJeu, lecteur: LecteurCatalogue, source: Reseau = reseauGlobal, signal?: AbortSignal): Promise { + if (economieDeDonnees()) return 0 + const file = mediasDuCatalogue(jeu) + let charges = 0 + const ouvrier = async () => { + for (let chemin = file.shift(); chemin !== undefined; chemin = file.shift()) { + if (signal?.aborted || source.lire().niveau !== 'en_ligne') return + try { + const reponse = await fetch(lecteur.url(chemin), { credentials: 'omit', signal }) + if (reponse.ok) { + await reponse.arrayBuffer() + charges++ + } + } catch { + return + } + } + } + await Promise.all(Array.from({ length: EN_PARALLELE }, ouvrier)) + return charges +} + +/** Lance le préchargement quand le navigateur est inactif. Renvoie de quoi l'annuler. */ +export function prechargerAuRepos(jeu: CatalogueJeu, lecteur: LecteurCatalogue, source: Reseau = reseauGlobal): () => void { + const controleur = new AbortController() + const lancer = () => void prechargerMedias(jeu, lecteur, source, controleur.signal) + const fenetre = window as Window & { requestIdleCallback?: (rappel: () => void, options?: { timeout: number }) => number; cancelIdleCallback?: (id: number) => void } + if (fenetre.requestIdleCallback) { + const id = fenetre.requestIdleCallback(lancer, { timeout: 10_000 }) + return () => { + fenetre.cancelIdleCallback?.(id) + controleur.abort() + } + } + const minuteur = setTimeout(lancer, 3_000) + return () => { + clearTimeout(minuteur) + controleur.abort() + } +} + +/** Demande au navigateur de ne pas vider la partie locale ni le catalogue gardé. */ +export function demanderStockagePersistant(): void { + void navigator.storage?.persist?.().catch(() => false) +} diff --git a/web/src/services/reseau.test.ts b/web/src/services/reseau.test.ts new file mode 100644 index 0000000..ca09451 --- /dev/null +++ b/web/src/services/reseau.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DELAI_HORS_LIGNE_MS, REPONSE_LENTE_MS, Reseau } from './reseau' + +describe('état du réseau (ADR 0085)', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('passe instable sur un échec ou une réponse lente, puis revient en ligne', () => { + const r = new Reseau(true) + r.abonner(() => undefined) + r.noterReussite(100) + expect(r.lire().niveau).toBe('en_ligne') + r.noterReussite(REPONSE_LENTE_MS + 1) + expect(r.lire().niveau).toBe('instable') + vi.advanceTimersByTime(30_000) + expect(r.lire().niveau).toBe('en_ligne') + r.noterEchec() + expect(r.lire().niveau).toBe('instable') + }) + + it('une perte met hors ligne, la sonde ramène en ligne et l’annonce 2 s', async () => { + const r = new Reseau(true) + const sonde = vi.fn().mockRejectedValueOnce(new Error('coupé')).mockResolvedValue({}) + r.configurerSonde(sonde) + r.abonner(() => undefined) + r.noterPerte() + expect(r.lire().niveau).toBe('hors_ligne') + await vi.advanceTimersByTimeAsync(2_000) + expect(sonde).toHaveBeenCalledTimes(1) + expect(r.lire().niveau).toBe('hors_ligne') + // Second essai, plus tard (4 s) : il répond. + await vi.advanceTimersByTimeAsync(4_000) + expect(r.lire()).toEqual({ niveau: 'en_ligne', retour: true }) + vi.advanceTimersByTime(2_000) + expect(r.lire()).toEqual({ niveau: 'en_ligne', retour: false }) + }) + + it('ne passe hors ligne qu’après 2 s de coupure annoncée par le navigateur', () => { + const r = new Reseau(true) + r.abonner(() => undefined) + const enLigne = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) + window.dispatchEvent(new Event('offline')) + vi.advanceTimersByTime(DELAI_HORS_LIGNE_MS - 1) + expect(r.lire().niveau).toBe('en_ligne') + // Micro-coupure : le réseau revient avant 2 s, rien ne bouge. + enLigne.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + vi.advanceTimersByTime(DELAI_HORS_LIGNE_MS) + expect(r.lire().niveau).toBe('en_ligne') + enLigne.mockReturnValue(false) + window.dispatchEvent(new Event('offline')) + vi.advanceTimersByTime(DELAI_HORS_LIGNE_MS) + expect(r.lire().niveau).toBe('hors_ligne') + }) +}) diff --git a/web/src/services/reseau.ts b/web/src/services/reseau.ts index 71460e6..a7c022b 100644 --- a/web/src/services/reseau.ts +++ b/web/src/services/reseau.ts @@ -1,15 +1,177 @@ +// État du réseau (ADR 0085) : `navigator.onLine` se dit vrai dès qu'une antenne +// répond, même si rien ne passe. L'état se déduit donc aussi des requêtes : un +// essai qui échoue rend le réseau instable, une commande qui épuise ses essais +// le met hors ligne, et une sonde légère guette son retour. + import { useSyncExternalStore } from 'react' -function abonner(rappel: () => void): () => void { - window.addEventListener('online', rappel) - window.addEventListener('offline', rappel) - return () => { - window.removeEventListener('online', rappel) - window.removeEventListener('offline', rappel) +export type NiveauReseau = 'en_ligne' | 'instable' | 'hors_ligne' + +export interface EtatReseau { + niveau: NiveauReseau + /** Vrai pendant 2 s après un retour en ligne : « De retour en ligne ». */ + retour: boolean +} + +/** Le navigateur doit se dire hors ligne depuis ce délai : une micro-coupure ne fait pas clignoter la pastille. */ +export const DELAI_HORS_LIGNE_MS = 2_000 +/** Une réponse plus lente que ce seuil rend le réseau instable. */ +export const REPONSE_LENTE_MS = 3_000 +/** Durée pendant laquelle le réseau reste « instable » après le dernier signe de faiblesse. */ +const DUREE_INSTABLE_MS = 30_000 +/** Durée du message « De retour en ligne ». */ +const DUREE_RETOUR_MS = 2_000 +/** Intervalles de la sonde hors ligne : 2 s, puis de plus en plus long, jusqu'à 30 s. */ +const SONDES_MS = [2_000, 4_000, 8_000, 15_000, 30_000] + +export class Reseau { + private etat: EtatReseau + private readonly abonnes = new Set<() => void>() + private sonde: (() => Promise) | undefined + private minuteurHorsLigne: ReturnType | undefined + private minuteurInstable: ReturnType | undefined + private minuteurRetour: ReturnType | undefined + private minuteurSonde: ReturnType | undefined + private essaiSonde = 0 + private ecoute = false + + constructor(enLigne = typeof navigator === 'undefined' || navigator.onLine !== false) { + this.etat = { niveau: enLigne ? 'en_ligne' : 'hors_ligne', retour: false } + } + + lire = (): EtatReseau => this.etat + + /** Remet l'état à zéro, sans minuteur en cours (tests). */ + reinitialiser(enLigne = true): void { + for (const m of [this.minuteurHorsLigne, this.minuteurInstable, this.minuteurRetour, this.minuteurSonde]) clearTimeout(m) + this.minuteurSonde = undefined + this.essaiSonde = 0 + this.changer({ niveau: enLigne ? 'en_ligne' : 'hors_ligne', retour: false }) + } + + abonner = (rappel: () => void): (() => void) => { + this.ecouterNavigateur() + this.abonnes.add(rappel) + if (this.etat.niveau === 'hors_ligne') this.planifierSonde() + return () => this.abonnes.delete(rappel) + } + + /** Requête légère qui dit si le serveur répond (`GET /sante`). */ + configurerSonde(sonde: () => Promise): void { + this.sonde = sonde + if (this.etat.niveau === 'hors_ligne') this.planifierSonde() + } + + /** Une réponse est arrivée : le réseau passe, lentement ou non. */ + noterReussite(dureeMs: number): void { + if (this.etat.niveau === 'hors_ligne') { + this.revenir(dureeMs > REPONSE_LENTE_MS ? 'instable' : 'en_ligne') + return + } + if (dureeMs > REPONSE_LENTE_MS) this.devenirInstable() + } + + /** Un essai a échoué (coupure, délai dépassé, passerelle) : un autre va suivre. */ + noterEchec(): void { + if (this.etat.niveau !== 'hors_ligne') this.devenirInstable() + } + + /** Une réponse tarde au-delà de `REPONSE_LENTE_MS`, sans être encore venue. */ + noterLenteur(): void { + if (this.etat.niveau !== 'hors_ligne') this.devenirInstable() } + + /** Une commande a épuisé ses essais : le réseau est perdu, la sonde guette son retour. */ + noterPerte(): void { + this.passerHorsLigne() + } + + private changer(etat: EtatReseau): void { + if (etat.niveau === this.etat.niveau && etat.retour === this.etat.retour) return + this.etat = etat + this.abonnes.forEach((rappel) => rappel()) + } + + private devenirInstable(): void { + clearTimeout(this.minuteurInstable) + this.minuteurInstable = setTimeout(() => { + if (this.etat.niveau === 'instable') this.changer({ niveau: 'en_ligne', retour: false }) + }, DUREE_INSTABLE_MS) + this.changer({ niveau: 'instable', retour: false }) + } + + private passerHorsLigne(): void { + clearTimeout(this.minuteurInstable) + clearTimeout(this.minuteurRetour) + if (this.etat.niveau !== 'hors_ligne') this.essaiSonde = 0 + this.changer({ niveau: 'hors_ligne', retour: false }) + this.planifierSonde() + } + + private revenir(niveau: Exclude): void { + clearTimeout(this.minuteurSonde) + this.minuteurSonde = undefined + this.essaiSonde = 0 + clearTimeout(this.minuteurRetour) + this.minuteurRetour = setTimeout(() => this.changer({ ...this.etat, retour: false }), DUREE_RETOUR_MS) + this.changer({ niveau, retour: true }) + if (niveau === 'instable') this.devenirInstable() + } + + private planifierSonde(): void { + if (!this.sonde || this.minuteurSonde !== undefined || this.abonnes.size === 0) return + const delai = SONDES_MS[Math.min(this.essaiSonde, SONDES_MS.length - 1)] ?? 30_000 + this.minuteurSonde = setTimeout(() => void this.sonder(), delai) + } + + /** Sonde tout de suite, par exemple quand le navigateur annonce le retour du réseau. */ + private async sonder(): Promise { + clearTimeout(this.minuteurSonde) + this.minuteurSonde = undefined + if (this.etat.niveau !== 'hors_ligne' || !this.sonde) return + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + this.essaiSonde++ + this.planifierSonde() + return + } + try { + await this.sonde() + // La sonde passe par le client de l'API : sa réussite a déjà été notée. + if (this.etat.niveau === 'hors_ligne') this.revenir('en_ligne') + } catch { + this.essaiSonde++ + this.planifierSonde() + } + } + + private ecouterNavigateur(): void { + if (this.ecoute || typeof window === 'undefined') return + this.ecoute = true + window.addEventListener('offline', () => { + clearTimeout(this.minuteurHorsLigne) + this.minuteurHorsLigne = setTimeout(() => { + if (navigator.onLine === false) this.passerHorsLigne() + }, DELAI_HORS_LIGNE_MS) + }) + window.addEventListener('online', () => { + clearTimeout(this.minuteurHorsLigne) + // Le navigateur voit une antenne : on vérifie que le serveur répond. + if (this.etat.niveau === 'hors_ligne') { + if (this.sonde) void this.sonder() + else this.revenir('en_ligne') + } + }) + } +} + +/** État réseau de l'application, partagé par le client de l'API et l'interface. */ +export const reseau = new Reseau() + +export function useReseau(source: Reseau = reseau): EtatReseau { + return useSyncExternalStore(source.abonner, source.lire) } -/** Vrai tant que le navigateur se dit en ligne. */ -export function useEnLigne(): boolean { - return useSyncExternalStore(abonner, () => navigator.onLine) +/** Vrai tant que le réseau n'est pas perdu (en ligne ou instable). */ +export function useEnLigne(source: Reseau = reseau): boolean { + return useReseau(source).niveau !== 'hors_ligne' } diff --git a/web/src/styles.css b/web/src/styles.css index 8c494ae..2357935 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -246,6 +246,21 @@ button { font: inherit; } } .banniere .ic { flex: none; } +/* + * Pastille de l'état du réseau (ADR 0085), dans l'en-tête de chaque écran : Sable et Encre comme la + * bannière, une icône et un mot (la couleur n'est jamais le seul signal). Vide en ligne. + */ +.pastille-reseau { + display: inline-flex; align-items: center; gap: 6px; min-height: 32px; margin: 0; + padding: 4px 12px; border: 1.5px solid var(--line-strong); border-radius: 999px; + background: var(--paper-2); color: var(--ink); font-size: 13px; font-weight: 600; white-space: nowrap; +} +.pastille-reseau:empty { display: none; } +.pastille-reseau .ic { flex: none; width: 18px; height: 18px; } +.pastille-reseau .pastille-detail { color: var(--ink-2); font-weight: 400; } +.pastille-reseau[data-niveau="hors_ligne"] { border-style: dashed; } +@media (max-width: 520px) { .pastille-reseau .pastille-detail { display: none; } } + /* Écran de lancement au sceau ÉL (I-03). */ .lancement { display: grid; justify-items: center; gap: 16px; padding: 80px 0; color: var(--ink-2); } .lancement .seal { width: 64px; height: 64px; border-radius: 18px; font-size: 28px; box-shadow: 5px 5px 0 var(--fixed-ink); } @@ -475,6 +490,9 @@ button.brick.is-out:hover, button.brick.is-spent:hover, button.brick.is-out:acti .feedback[data-tone="fail"]::before { background: var(--ink-3); } .feedback[data-tone="known"]::before { background: var(--ink); } .feedback[data-tone="wait"]::before { background: transparent; box-shadow: inset 0 0 0 1.5px var(--ink-3); } +/* Erreur de réseau (ADR 0085) : ni pastille d'échec ni teinte, l'icône hors ligne dit ce qui s'est passé. */ +.feedback[data-tone="reseau"]::before { display: none; } +.feedback[data-tone="reseau"] .ic { flex: none; width: 18px; height: 18px; } .feedback .bchip { margin-inline: 2px; } /* Relève de message (Resultat.tsx) : l'ancien retour, rendu par-dessus, sort par le haut. */ /* Rogné juste au-delà de ses bords : focus et ombres des boutons restent visibles. */ @@ -564,6 +582,10 @@ button.disc { font: inherit; cursor: pointer; } /* Refus doux, en deux forces (mouvement.html) : « échec » 500 ms, « presque » 300 ms, une oscillation qui s'amortit. */ :root:not([data-mouvement="reduit"]) .slots.shake { animation: amorti 0.5s linear; } :root:not([data-mouvement="reduit"]) .slots.shake.legere { animation: amorti-leger 0.3s ease-out; } + /* Réponse qui tarde (ADR 0085, après 1 s) : les briques se rapprochent sans s'emboîter, le « + » s'efface à moitié. */ + :root:not([data-mouvement="reduit"]) .slots.attente .op { opacity: 0.4; transition: opacity var(--dur-lent) var(--ease-out); } + :root:not([data-mouvement="reduit"]) .slots.attente .slot[data-slot="0"] .brick { animation: rapproche 1.6s ease-in-out infinite; --vers: 6px; } + :root:not([data-mouvement="reduit"]) .slots.attente .slot[data-slot="1"] .brick { animation: rapproche 1.6s ease-in-out infinite; --vers: -6px; } /* * Découverte (Table.tsx, 700 ms) : les briques s'aimantent (--fx, --fy mesurés), s'écrasent au contact, * puis leurs étiquettes se fondent en un mot ; huit traits d'encre et des lettres jaillissent autour. @@ -609,6 +631,9 @@ button.disc { font: inherit; cursor: pointer; } to { box-shadow: 0 0 0 16px color-mix(in srgb, var(--c-action) 0%, transparent); } } @keyframes souffle { 50% { --souffle: 1px; } } +/* Va-et-vient de deux briques qui attendent la réponse du serveur : elles s'approchent, jamais ne se touchent. */ +@keyframes rapproche { 50% { translate: var(--vers) 0; } } +@keyframes rapproche-v { 50% { translate: 0 var(--vers); } } @keyframes amorti { 12% { transform: translateX(-8px); } 25% { transform: translateX(8px); } 38% { transform: translateX(-6px); } 50% { transform: translateX(6px); } @@ -647,6 +672,8 @@ button.disc { font: inherit; cursor: pointer; } .slots { flex-direction: column; gap: 0; min-height: 0; padding: 8px 0; } .slot, .slot:not(.filled) { flex: none; width: auto; height: 72px; min-width: 160px; } .op { font-size: 24px; line-height: 1.1; } + /* Cases l'une au-dessus de l'autre : le rapprochement se fait à la verticale. */ + :root:not([data-mouvement="reduit"]) .slots.attente .slot[data-slot] .brick { animation-name: rapproche-v; } } /* Petits écrans (etymo.css, max-width 520 px). */ @media (max-width: 520px) { diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 2bb30cc..f24a6b7 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -3,8 +3,12 @@ import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/react' import { afterEach, vi } from 'vitest' +import { reseau } from '../services/reseau' + afterEach(() => { cleanup() vi.unstubAllGlobals() vi.restoreAllMocks() + // L'état réseau est partagé : un test qui perd le réseau ne le perd pas pour les suivants. + reseau.reinitialiser() })