diff --git a/Cargo.lock b/Cargo.lock index 69ebe0e..d5644c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -479,9 +479,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 +580,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]] @@ -1845,7 +1845,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2098,14 +2098,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 +2156,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2167,9 +2167,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", @@ -2727,10 +2727,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 +2780,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", @@ -3234,9 +3234,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 +3247,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.11", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -3291,7 +3291,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]] diff --git a/contrat/openapi.json b/contrat/openapi.json index 0351e88..1381ea8 100644 --- a/contrat/openapi.json +++ b/contrat/openapi.json @@ -570,7 +570,7 @@ "tags": [ "jeu" ], - "summary": "Répond à une question de ricochet (ADR 0044). Le serveur juge sur les\nidentifiants, applique la règle de maîtrise et compte la réponse parmi les\ncinq du jour. Une carte pas à revoir renvoie son résultat sans changer de niveau.\nRien n'est consommé : ni exemplaire, ni énergie, ni encre.", + "summary": "Répond à une question de ricochet (ADR 0044). Le serveur juge sur les\nidentifiants, applique la règle de maîtrise et compte la réponse parmi les\ncinq du jour. Une carte pas à revoir renvoie son résultat sans changer de niveau.\nRien n'est consommé : ni exemplaire, ni énergie, ni encre. Une coupe juste de\n*découper* qui laisse des coupes à poser (`suite = continue`, ADR 0084) ne\nmodifie rien : seule la réponse qui clôt la question est une commande.", "operationId": "repondre_ricochet", "requestBody": { "content": { @@ -2764,6 +2764,17 @@ }, "description": "Positions de coupe de la composition préférée ; vide : le mot ne se découpe pas." }, + "decoupage": { + "oneOf": [ + { + "$ref": "#/components/schemas/Noeud", + "description": "Arbre des coupes justes, compositions chaînées comprises (ADR 0084). Absent d'un\nmanifeste antérieur : `coupes` seules font alors foi, sur un seul niveau." + }, + { + "type": "null" + } + ] + }, "id": { "$ref": "#/components/schemas/IdCatalogue" } @@ -2801,6 +2812,44 @@ "mot" ] }, + "Noeud": { + "type": "object", + "description": "Un morceau du mot et ses coupes justes.", + "required": [ + "debut", + "fin", + "coupes" + ], + "properties": { + "coupes": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "description": "Positions des coupes de ce morceau, dans le mot entier." + }, + "debut": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "fin": { + "type": "integer", + "format": "int32", + "description": "Fin exclue.", + "minimum": 0 + }, + "parties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Noeud" + }, + "description": "Sous-nœuds : les parties qui se coupent à leur tour." + } + } + }, "OuverturePli": { "type": "object", "required": [ @@ -3267,7 +3316,11 @@ }, "juste": { "type": "boolean", - "description": "Vrai si la réponse est la bonne, que la carte ait été à revoir ou non." + "description": "Vrai si la réponse est la bonne, que la carte ait été à revoir ou non.\nPour une coupe de *découper* qui laisse la question ouverte : vrai." + }, + "suite": { + "$ref": "#/components/schemas/SuiteRicochet", + "description": "`continue` : *découper* a encore des coupes à poser (ADR 0084), rien n'est\nécrit ni compté. Absent des anciennes réponses : `terminee`." } }, "additionalProperties": false @@ -3661,6 +3714,14 @@ "degrade" ] }, + "SuiteRicochet": { + "type": "string", + "description": "Où en est la question après la réponse.", + "enum": [ + "terminee", + "continue" + ] + }, "Transformation": { "type": "object", "required": [ diff --git a/crates/api/src/ricochets.rs b/crates/api/src/ricochets.rs index 093b50b..b7082ee 100644 --- a/crates/api/src/ricochets.rs +++ b/crates/api/src/ricochets.rs @@ -10,6 +10,7 @@ use etymo_contrat::{ FormatRicochet, QuestionRicochet, ReponseJoueur, RicochetEtat, RicochetsDuJour, }; use etymo_domaine::IdCatalogue; +use etymo_domaine::decoupage::{self, Verdict}; use etymo_domaine::ricochet::{self, Carte}; use sqlx::PgConnection; use uuid::Uuid; @@ -176,7 +177,7 @@ fn question<'a>( { formats.push(FormatRicochet::MotSens); } - if !m.coupes.is_empty() { + if m.arbre().is_some() { formats.push(FormatRicochet::Decouper); } } @@ -369,26 +370,51 @@ pub fn juger( .ok_or_else(|| invalide("reponse : un mot connu attendu"))?; Ok(choisi == unite || autre.classe_litteral.as_ref() == Some(classe)) } - (FormatRicochet::Decouper, ReponseJoueur::Coupes(coupes)) => { - let mot = (unite.prefixe() == "unt") - .then(|| ricochets.mots.get(unite)) - .flatten() - .ok_or_else(inconnue)?; - if mot.coupes.is_empty() { - return Err(invalide("format : ce mot ne se découpe pas")); - } - if coupes.len() > COUPES_MAX { - return Err(invalide("reponse : trop de coupes")); - } - let mut coupes = coupes.clone(); - coupes.sort_unstable(); - coupes.dedup(); - Ok(coupes == mot.coupes) - } _ => Err(invalide("reponse : forme incompatible avec le format")), } } +/// Ce que vaut une réponse. Seul *découper* a un entre-deux : les coupes posées +/// sont justes, mais il en reste (ADR 0084). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Jugement { + Faux, + Juste, + /// Question pas terminée : rien ne s'écrit. + Continue, +} + +/// Juge une réponse, découper en chaîne compris (`etymo_domaine::decoupage`). +pub fn jugement( + jeu: &Jeu, + format: FormatRicochet, + unite: &IdCatalogue, + reponse: &ReponseJoueur, +) -> Result { + let (FormatRicochet::Decouper, ReponseJoueur::Coupes(coupes)) = (format, reponse) else { + return Ok(if juger(jeu, format, unite, reponse)? { + Jugement::Juste + } else { + Jugement::Faux + }); + }; + let mot = (unite.prefixe() == "unt") + .then(|| jeu.ricochets.mots.get(unite)) + .flatten() + .ok_or_else(|| ErreurApi::Interdit("carte inconnue"))?; + let arbre = mot + .arbre() + .ok_or_else(|| invalide("format : ce mot ne se découpe pas"))?; + if coupes.len() > COUPES_MAX { + return Err(invalide("reponse : trop de coupes")); + } + Ok(match decoupage::juger(&arbre, coupes) { + Verdict::Faux => Jugement::Faux, + Verdict::Continue => Jugement::Continue, + Verdict::Termine => Jugement::Juste, + }) +} + // -------------------------------------------------------------------- base fn id(texte: &str) -> Result { @@ -613,6 +639,7 @@ mod tests { id: mot(n), classe_litteral: Some(mot(classe)), coupes: if n <= 2 { vec![3] } else { vec![] }, + decoupage: None, }, ); } @@ -783,8 +810,11 @@ mod tests { assert!(!juger(&jeu, FormatRicochet::MotSens, &mot(1), &choix(mot(2))).unwrap()); // Découper : l'ordre et les doublons ne comptent pas. let coupes = |c: &[u32]| ReponseJoueur::Coupes(c.to_vec()); - assert!(juger(&jeu, FormatRicochet::Decouper, &mot(1), &coupes(&[3, 3])).unwrap()); - assert!(!juger(&jeu, FormatRicochet::Decouper, &mot(1), &coupes(&[2])).unwrap()); + let decouper = + |c: &[u32]| jugement(&jeu, FormatRicochet::Decouper, &mot(1), &coupes(c)).unwrap(); + assert_eq!(decouper(&[3, 3]), Jugement::Juste); + assert_eq!(decouper(&[2]), Jugement::Faux); + assert_eq!(decouper(&[]), Jugement::Continue); } #[test] @@ -816,7 +846,7 @@ mod tests { Err(ErreurApi::RequeteInvalide(_)) )); assert!(matches!( - juger( + jugement( &jeu, FormatRicochet::Decouper, &mot(3), @@ -825,7 +855,7 @@ mod tests { Err(ErreurApi::RequeteInvalide(_)) )); assert!(matches!( - juger( + jugement( &jeu, FormatRicochet::Decouper, &mot(1), diff --git a/crates/api/src/routes.rs b/crates/api/src/routes.rs index 1924639..07cded4 100644 --- a/crates/api/src/routes.rs +++ b/crates/api/src/routes.rs @@ -11,7 +11,7 @@ use etymo_contrat::{ DemandePli, DemandeRicochet, DemandeSablier, DemandeSession, Erreur, Etat, Explication, IssueFusion, OuverturePli, ReponseAchatSablier, ReponseFusion, ReponseIndice, ReponseJalon, ReponsePalier, ReponsePli, ReponseRicochet, ReponseSablier, Sante, SeanceRicochets, Session, - StatutSante, + StatutSante, SuiteRicochet, }; use etymo_domaine::fusion::{Issue, TABLE_MAX, TABLE_MIN, consommation, resoudre}; use etymo_domaine::{indice, pli, sablier}; @@ -847,7 +847,9 @@ pub async fn seance_ricochets( /// Répond à une question de ricochet (ADR 0044). Le serveur juge sur les /// identifiants, applique la règle de maîtrise et compte la réponse parmi les /// cinq du jour. Une carte pas à revoir renvoie son résultat sans changer de niveau. -/// Rien n'est consommé : ni exemplaire, ni énergie, ni encre. +/// Rien n'est consommé : ni exemplaire, ni énergie, ni encre. Une coupe juste de +/// *découper* qui laisse des coupes à poser (`suite = continue`, ADR 0084) ne +/// modifie rien : seule la réponse qui clôt la question est une commande. #[utoipa::path(post, path = "/ricochet", tag = "jeu", security(("session" = [])), request_body = DemandeRicochet, responses( (status = 200, description = "Résultat, carte après la réponse et état confirmé", body = ReponseRicochet), @@ -876,7 +878,7 @@ pub async fn repondre_ricochet( Err(reponse) => return Ok(Json(reponse)), }; let catalogue = app.catalogue.courant().ok_or(ErreurApi::Indisponible)?; - let juste = ricochets::juger( + let jugement = ricochets::jugement( &catalogue.jeu, demande.format, &demande.unite_id, @@ -885,11 +887,25 @@ pub async fn repondre_ricochet( if !ricochets::est_trouvee(&mut tx, joueur, &demande.unite_id).await? { return Err(ErreurApi::Interdit("carte pas encore trouvée")); } - let carte = - ricochets::enregistrer(&mut tx, joueur, &demande.unite_id, juste, Utc::now()).await?; - progression::incrementer_version(&mut tx, joueur).await?; + let (juste, suite, carte) = if jugement == ricochets::Jugement::Continue { + // Une coupe juste de *découper* qui laisse la question ouverte (ADR 0084) : + // ni commande, ni version, ni niveau, ni réponse du jour. La transaction + // se referme sans rien écrire ; seule la commande qui clôt la question compte. + let carte = ricochets::lire(&mut tx, joueur) + .await? + .remove(&demande.unite_id) + .unwrap_or_else(|| etymo_domaine::ricochet::Carte::neuve(Utc::now().date_naive())); + (true, SuiteRicochet::Continue, carte) + } else { + let juste = jugement == ricochets::Jugement::Juste; + let carte = + ricochets::enregistrer(&mut tx, joueur, &demande.unite_id, juste, Utc::now()).await?; + progression::incrementer_version(&mut tx, joueur).await?; + (juste, SuiteRicochet::Terminee, carte) + }; let reponse = ReponseRicochet { juste, + suite, carte: CarteRicochet { niveau: carte.niveau, maitrisee: carte.maitrisee, @@ -898,6 +914,9 @@ pub async fn repondre_ricochet( }, etat: etat_courant(&app, &mut tx, joueur).await?, }; + if suite == SuiteRicochet::Continue { + return Ok(Json(reponse)); + } conclure_commande(tx, joueur, demande.commande, "ricochet", &reponse).await?; Ok(Json(reponse)) } diff --git a/crates/api/tests/ricochet.rs b/crates/api/tests/ricochet.rs index fe99060..a0b4dbe 100644 --- a/crates/api/tests/ricochet.rs +++ b/crates/api/tests/ricochet.rs @@ -198,9 +198,68 @@ async fn un_mot_decouvert_se_decoupe(base: PgPool) { assert_eq!(statut, StatusCode::OK, "{reponse}"); assert_eq!(reponse["juste"], false); - let bon = json!({ "format": "decouper", "unite_id": MOT, "reponse": [3] }); + assert_eq!(reponse["suite"], "terminee"); + + // L'arbre d'essai a deux niveaux : [3], puis [3, 5]. + let bon = json!({ "format": "decouper", "unite_id": MOT, "reponse": [3, 5] }); let (_, reponse) = repondre(&banc, &jeton, &reponse["etat"], bon).await; assert_eq!(reponse["juste"], true); + assert_eq!(reponse["suite"], "terminee"); // La carte venait d'être ratée : à revoir demain, pas aujourd'hui. assert_eq!(reponse["carte"]["niveau"], 0); } + +#[sqlx::test(migrations = "../../migrations")] +async fn une_coupe_juste_qui_laisse_des_coupes_ne_change_rien(base: PgPool) { + let banc = banc(base, Limiteurs::default()).await; + let jeton = session(&banc.routeur).await; + let (_, _, etat) = appeler(&banc.routeur, get_etat(&jeton)).await; + let fusion = commande(&etat, json!({ "briques": [GEO, LOGIE] })); + let (_, _, decouverte) = appeler(&banc.routeur, post_fusion(&jeton, &fusion)).await; + let etat = &decouverte["etat"]; + + // Premier niveau : juste, mais il reste à recouper « logie ». + let premier = json!({ "format": "decouper", "unite_id": MOT, "reponse": [3] }); + let (statut, reponse) = repondre(&banc, &jeton, etat, premier.clone()).await; + assert_eq!(statut, StatusCode::OK, "{reponse}"); + assert_eq!(reponse["juste"], true); + assert_eq!(reponse["suite"], "continue"); + assert_eq!( + reponse["etat"]["version_etat"], etat["version_etat"], + "aucune commande n'est écrite" + ); + assert_eq!( + reponse["etat"]["ricochets_du_jour"], + etat["ricochets_du_jour"] + ); + assert_eq!(reponse["carte"]["niveau"], 0); + // Rejouée, la coupe reçoit la même réponse et ne coûte toujours rien. + let (_, encore) = repondre(&banc, &jeton, etat, premier).await; + assert_eq!(encore["suite"], "continue"); + + // Le morceau « logie » ne se coupe pas avant d'avoir été séparé du reste : la question s'arrête, fausse. + let trop_tot = json!({ "format": "decouper", "unite_id": MOT, "reponse": [5] }); + let (_, reponse) = repondre(&banc, &jeton, etat, trop_tot).await; + assert_eq!(reponse["juste"], false); + assert_eq!(reponse["suite"], "terminee"); + assert_ne!( + reponse["etat"]["version_etat"], etat["version_etat"], + "la réponse qui clôt est une commande" + ); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn la_derniere_coupe_juste_cloture_la_question(base: PgPool) { + let banc = banc(base, Limiteurs::default()).await; + let jeton = session(&banc.routeur).await; + let (_, _, etat) = appeler(&banc.routeur, get_etat(&jeton)).await; + let fusion = commande(&etat, json!({ "briques": [GEO, LOGIE] })); + let (_, _, decouverte) = appeler(&banc.routeur, post_fusion(&jeton, &fusion)).await; + let etat = &decouverte["etat"]; + + let tout = json!({ "format": "decouper", "unite_id": MOT, "reponse": [3, 5] }); + let (_, reponse) = repondre(&banc, &jeton, etat, tout).await; + assert_eq!(reponse["juste"], true); + assert_eq!(reponse["suite"], "terminee"); + assert_ne!(reponse["etat"]["version_etat"], etat["version_etat"]); +} diff --git a/crates/catalogue/src/essai.rs b/crates/catalogue/src/essai.rs index 5532dd2..d3ff92f 100644 --- a/crates/catalogue/src/essai.rs +++ b/crates/catalogue/src/essai.rs @@ -129,7 +129,10 @@ pub fn fascicule(racine: &Path, numero: u32, parution: &str) -> String { { "id": GEO, "sens_id": SENS_GEO, "classe_sens": SENS_GEO }, { "id": LOGIE, "sens_id": SENS_LOGIE, "classe_sens": SENS_LOGIE } ], - "mots": [{ "id": MOT, "classe_litteral": MOT, "coupes": [3] }] + "mots": [{ "id": MOT, "classe_litteral": MOT, "coupes": [3], + "decoupage": { "debut": 0, "fin": 8, "coupes": [3], "parties": [ + { "debut": 3, "fin": 8, "coupes": [5], "parties": [] } + ] } }] }, "pli": { "briques": [ diff --git a/crates/catalogue/src/format.rs b/crates/catalogue/src/format.rs index b75af8a..557456b 100644 --- a/crates/catalogue/src/format.rs +++ b/crates/catalogue/src/format.rs @@ -7,6 +7,7 @@ use std::collections::BTreeSet; use chrono::{DateTime, FixedOffset}; use etymo_domaine::IdCatalogue; +use etymo_domaine::decoupage::Noeud; use etymo_domaine::jalon::Jalon; use etymo_domaine::pli::{Equilibrage, NaturePli, Rarete}; use etymo_domaine::quete::QueteLongCours; @@ -115,6 +116,22 @@ pub struct MotRicochet { /// Positions de coupe de la composition préférée ; vide : le mot ne se découpe pas. #[serde(default)] pub coupes: Vec, + /// Arbre des coupes justes, compositions chaînées comprises (ADR 0084). Absent d'un + /// manifeste antérieur : `coupes` seules font alors foi, sur un seul niveau. + #[serde(default)] + pub decoupage: Option, +} + +impl MotRicochet { + /// Arbre à juger : le `decoupage` publié, ou à défaut les `coupes` du premier niveau. + #[must_use] + pub fn arbre(&self) -> Option> { + match (&self.decoupage, self.coupes.is_empty()) { + (Some(arbre), _) => Some(std::borrow::Cow::Borrowed(arbre)), + (None, false) => Some(std::borrow::Cow::Owned(Noeud::plat(&self.coupes))), + (None, true) => None, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/contrat/src/lib.rs b/crates/contrat/src/lib.rs index f34e927..967d916 100644 --- a/crates/contrat/src/lib.rs +++ b/crates/contrat/src/lib.rs @@ -186,6 +186,17 @@ pub struct DemandeRicochet { pub reponse: ReponseJoueur, } +/// Où en est la question après la réponse. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SuiteRicochet { + /// La question est close : la réponse est comptée, juste ou fausse. + #[default] + Terminee, + /// La coupe est juste et il en reste à poser : rien n'est écrit. + Continue, +} + /// Carte après la réponse : de quoi annoncer son retour (« Demain », « Maîtrisée »). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] @@ -204,7 +215,12 @@ pub struct CarteRicochet { #[serde(deny_unknown_fields)] pub struct ReponseRicochet { /// Vrai si la réponse est la bonne, que la carte ait été à revoir ou non. + /// Pour une coupe de *découper* qui laisse la question ouverte : vrai. pub juste: bool, + /// `continue` : *découper* a encore des coupes à poser (ADR 0084), rien n'est + /// écrit ni compté. Absent des anciennes réponses : `terminee`. + #[serde(default)] + pub suite: SuiteRicochet, pub carte: CarteRicochet, /// État confirmé après la commande. pub etat: Etat, diff --git a/crates/domaine/src/decoupage.rs b/crates/domaine/src/decoupage.rs new file mode 100644 index 0000000..04bd8f0 --- /dev/null +++ b/crates/domaine/src/decoupage.rs @@ -0,0 +1,192 @@ +//! Découper en chaîne (ADR 0084) : l'arbre des coupes justes d'un mot et le +//! jugement d'un ensemble de coupes posées. +//! +//! Un nœud est un morceau du mot (`[debut, fin)`) avec les positions où sa +//! composition préférée le coupe, et un sous-nœud pour chaque partie qui est +//! elle-même un mot composé. Des positions seulement : aucun texte. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +/// Un morceau du mot et ses coupes justes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Noeud { + pub debut: u32, + /// Fin exclue. + pub fin: u32, + /// Positions des coupes de ce morceau, dans le mot entier. + pub coupes: Vec, + /// Sous-nœuds : les parties qui se coupent à leur tour. + #[serde(default)] + #[schema(no_recursion)] + pub parties: Vec, +} + +impl Noeud { + /// Arbre d'un seul niveau, pour un mot publié avec ses seules `coupes` + /// (manifeste antérieur à l'ADR 0084). Sa fin n'est pas connue. + #[must_use] + pub fn plat(coupes: &[u32]) -> Self { + let mut coupes = coupes.to_vec(); + coupes.sort_unstable(); + coupes.dedup(); + Self { + debut: 0, + fin: u32::MAX, + coupes, + parties: Vec::new(), + } + } + + fn rassembler<'a>(&'a self, noeuds: &mut Vec<&'a Noeud>) { + noeuds.push(self); + for partie in &self.parties { + partie.rassembler(noeuds); + } + } +} + +/// Ce que valent les coupes posées. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + /// Une coupe n'est pas juste : la question s'arrête, fausse. + Faux, + /// Toutes les coupes posées sont justes, il en reste à poser. + Continue, + /// Toutes les coupes possibles sont posées : la question s'arrête, réussie. + Termine, +} + +/// Juge l'ensemble des coupes posées jusqu'ici, quel que soit leur ordre. +/// +/// Une coupe est juste si un nœud la porte et si les deux bords de ce nœud sont +/// des bords du mot ou des coupes déjà posées : *étymo · logiste* n'est pas juste +/// avant *étymologie · iste*. La validité ne fait que croître avec les coupes +/// posées, donc l'ordre n'importe pas. +#[must_use] +pub fn juger(racine: &Noeud, posees: &[u32]) -> Verdict { + let mut noeuds = Vec::new(); + racine.rassembler(&mut noeuds); + let attendues: BTreeSet = noeuds + .iter() + .flat_map(|n| n.coupes.iter().copied()) + .collect(); + let mut restantes: BTreeSet = posees.iter().copied().collect(); + let mut placees: BTreeSet = BTreeSet::new(); + loop { + let juste = restantes.iter().copied().find(|c| { + noeuds.iter().any(|n| { + n.coupes.contains(c) + && [n.debut, n.fin] + .iter() + .all(|b| *b == racine.debut || *b == racine.fin || placees.contains(b)) + }) + }); + match juste { + Some(c) => { + restantes.remove(&c); + placees.insert(c); + } + None => break, + } + } + if !restantes.is_empty() { + Verdict::Faux + } else if placees == attendues { + Verdict::Termine + } else { + Verdict::Continue + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feuille(debut: u32, fin: u32, coupes: &[u32]) -> Noeud { + Noeud { + debut, + fin, + coupes: coupes.to_vec(), + parties: Vec::new(), + } + } + + /// *étymologiste* : *étymologie · iste*, puis *étymo · logie*. + fn etymologiste() -> Noeud { + Noeud { + debut: 0, + fin: 13, + coupes: vec![10], + parties: vec![feuille(0, 10, &[5])], + } + } + + #[test] + fn un_mot_a_un_niveau() { + let geologie = feuille(0, 8, &[3]); + assert_eq!(juger(&geologie, &[]), Verdict::Continue); + assert_eq!(juger(&geologie, &[3]), Verdict::Termine); + assert_eq!(juger(&geologie, &[3, 3]), Verdict::Termine); + assert_eq!(juger(&geologie, &[2]), Verdict::Faux); + assert_eq!(juger(&geologie, &[3, 5]), Verdict::Faux); + } + + #[test] + fn la_chaine_se_coupe_niveau_par_niveau() { + let mot = etymologiste(); + assert_eq!(juger(&mot, &[10]), Verdict::Continue); + assert_eq!(juger(&mot, &[10, 5]), Verdict::Termine); + } + + #[test] + fn le_sous_niveau_attend_son_morceau() { + let mot = etymologiste(); + // étymo · logiste n'existe pas : le morceau « étymologie » n'est pas encore coupé du reste. + assert_eq!(juger(&mot, &[5]), Verdict::Faux); + assert_eq!( + juger(&mot, &[5, 10]), + Verdict::Termine, + "l'ordre n'importe pas dans l'ensemble posé" + ); + } + + #[test] + fn une_coupe_fausse_apres_une_juste_est_fausse() { + assert_eq!(juger(&etymologiste(), &[10, 7]), Verdict::Faux); + assert_eq!(juger(&etymologiste(), &[10, 5, 7]), Verdict::Faux); + assert_eq!(juger(&etymologiste(), &[0]), Verdict::Faux); + assert_eq!(juger(&etymologiste(), &[99]), Verdict::Faux); + } + + #[test] + fn trois_parties_se_coupent_dans_un_ordre_libre() { + let mot = feuille(0, 12, &[3, 8]); + assert_eq!(juger(&mot, &[8]), Verdict::Continue); + assert_eq!(juger(&mot, &[3]), Verdict::Continue); + assert_eq!(juger(&mot, &[8, 3]), Verdict::Termine); + } + + #[test] + fn partie_finale_en_chaine() { + // préfixe · (mot composé de deux parties) : le sous-nœud est décalé. + let mot = Noeud { + debut: 0, + fin: 10, + coupes: vec![4], + parties: vec![feuille(4, 10, &[7])], + }; + assert_eq!(juger(&mot, &[7]), Verdict::Faux); + assert_eq!(juger(&mot, &[4]), Verdict::Continue); + assert_eq!(juger(&mot, &[4, 7]), Verdict::Termine); + } + + #[test] + fn mot_publie_a_plat() { + let mot = Noeud::plat(&[5, 3, 5]); + assert_eq!(juger(&mot, &[3, 5]), Verdict::Termine); + assert_eq!(juger(&mot, &[3]), Verdict::Continue); + assert_eq!(juger(&mot, &[4]), Verdict::Faux); + } +} diff --git a/crates/domaine/src/lib.rs b/crates/domaine/src/lib.rs index 5c35747..21ae344 100644 --- a/crates/domaine/src/lib.rs +++ b/crates/domaine/src/lib.rs @@ -1,5 +1,6 @@ //! Types et règles du jeu, sans entrée-sortie. +pub mod decoupage; pub mod energie; pub mod fusion; pub mod identifiant; diff --git a/docs/roadmap.md b/docs/roadmap.md index d8494ad..1a8f57e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -85,6 +85,7 @@ Reportés : file d’attente des fusions hors connexion (supprimée, ADR 0041), | Ricochets, API et base (App#18, [ADR 0044](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0044-ricochets-reviser-et-maitriser-les-cartes.md)) : tables `ricochet` et `ricochet_jour`, règle de maîtrise `etymo_domaine::ricochet`, `GET /ricochets/seance`, `POST /ricochet`, `Etat.ricochets` et `Etat.ricochets_du_jour`, données de jugement dans le manifeste (`ricochets`) ; cinq formats (sens d’une brique, brique d’un sens, mot d’un sens littéral, découper, paires), le format « littéral ou actuel » est abandonné | fait | | Ricochets, PWA (App#19) : onglet « Ricochets » dans le menu (écart à l’ADR 0044, qui n’ajoute aucune destination), séance de cinq questions, verdict immédiat, récapitulatif confirmé par le serveur | fait | | Ricochets, PWA : niveau sur la carte du codex, filet de quatre segments (niveaux 1 à 3, puis maîtrise) et galet de maîtrise dans le bandeau, mini-carte et carte ouverte | fait | +| Ricochets, Découper en chaîne ([ADR 0084](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0084-ricochets-decouper-en-chaine.md)) : arbre des coupes (`MotRicochet.decoupage`, compositions chaînées dépliées par Content), jugement par `etymo_domaine::decoupage` et son miroir `jugerCoupes`, une commande par coupe sans état (`ReponseRicochet.suite`), seule la commande qui clôt la question est une transaction ; la PWA coupe sur les jointures du mot, sans lame (écart à l’ADR 0053, non livré) | fait | | Ricochets, PWA : jauge « N / M maîtrisées » du fascicule | à faire | | « En savoir plus » (I-16) : chemin des langues, relations, transformations, certitude et sources citées ; dépliée depuis la carte du codex (`plus=1`) | fait (Content publie les types de relation et de transformation dans les référentiels) | diff --git a/web/package-lock.json b/web/package-lock.json index 217d8ce..c036780 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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "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.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "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.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "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.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "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.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz", + "integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==", "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.16", "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/composants/Decoupe.test.tsx b/web/src/composants/Decoupe.test.tsx new file mode 100644 index 0000000..0e3ab17 --- /dev/null +++ b/web/src/composants/Decoupe.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import type { QuestionRicochet } from '../contrat' +import type { CatalogueJeu } from '../services/jeu' +import type { DonneesRicochets, NoeudCoupes } from '../services/ricochets' +import { Decoupe } from './Ricochets' + +// « abcdefghij » : abcdefgh · ij, puis abcde · fgh. Des lettres neutres, un arbre à deux niveaux. +const arbre: NoeudCoupes = { debut: 0, fin: 10, coupes: [8], parties: [{ debut: 0, fin: 8, coupes: [5] }] } +const jeu = { cartes: new Map([['unt_1', { formes: [{ role: 'canonique', texte: 'abcdefghij' }], compositions: [] }]]), recettes: [], briques: new Map() } as unknown as CatalogueJeu +const d: DonneesRicochets = { briques: new Map(), gloses: new Map(), mots: new Map([['unt_1', { classe: null, coupes: [8], decoupage: arbre }]]) } +const q: QuestionRicochet = { format: 'decouper', cartes: ['unt_1'], choix: [] } + +function monter() { + const envoyer = vi.fn() + const repondu = vi.fn() + render() + const couper = (apres: string) => fireEvent.click(screen.getByRole('button', { name: new RegExp(`^Couper entre « ${apres} »`) })) + return { envoyer, repondu, couper } +} + +// jsdom ne défile pas. +Element.prototype.scrollIntoView = vi.fn() + +describe('Découper en chaîne', () => { + it('chaque coupe juste part au serveur ; la question reste ouverte jusqu’à la dernière', () => { + const { envoyer, repondu, couper } = monter() + couper('abcdefgh') + expect(envoyer).toHaveBeenLastCalledWith('decouper', 'unt_1', [8]) + expect(repondu).not.toHaveBeenCalled() + expect(screen.getByText('Un morceau se coupe encore.')).toBeInTheDocument() + couper('abcde') + expect(envoyer).toHaveBeenLastCalledWith('decouper', 'unt_1', [5, 8]) + expect(envoyer).toHaveBeenCalledTimes(2) + expect(repondu).toHaveBeenCalledTimes(1) + expect(screen.getByText('Bien vu.')).toBeInTheDocument() + }) + + it('une coupe fausse arrête la question, même après une coupe juste', () => { + const { envoyer, repondu, couper } = monter() + couper('abcdefgh') + couper('abc') + expect(envoyer).toHaveBeenLastCalledWith('decouper', 'unt_1', [3, 8]) + expect(repondu).toHaveBeenCalledTimes(1) + expect(screen.getByText('Pas cette fois.')).toBeInTheDocument() + }) + + it('le sous-niveau avant son morceau est faux', () => { + const { repondu, couper } = monter() + couper('abcde') + expect(repondu).toHaveBeenCalledTimes(1) + expect(screen.getByText('Pas cette fois.')).toBeInTheDocument() + }) + + it('une coupe déjà posée ne se renvoie pas', () => { + const { envoyer, couper } = monter() + couper('abcdefgh') + couper('abcdefgh') + expect(envoyer).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/composants/Ricochets.tsx b/web/src/composants/Ricochets.tsx index 5097435..000a04b 100644 --- a/web/src/composants/Ricochets.tsx +++ b/web/src/composants/Ricochets.tsx @@ -11,7 +11,10 @@ import { donneesRicochets, juger, melanger, + arbreDuMot, briquesDuMot, + feuillesDuMot, + jugerCoupes, morceauxDuMot, motDe, retour, @@ -154,11 +157,14 @@ function preparer(q: QuestionRicochet, jeu: CatalogueJeu, d: DonneesRicochets): } default: { const mot = motDe(jeu, cible) - const morceaux = (d.mots.get(cible)?.coupes.length ?? 1) + 1 - const parties = morceauxDuMot(jeu, cible) + const arbre = arbreDuMot(d, cible) + const enChaine = (arbre?.parties?.length ?? 0) > 0 + const morceaux = (arbre?.coupes.length ?? 1) + 1 + // Au verdict, tous les morceaux élémentaires de l'arbre (ADR 0084), pas seulement le premier niveau. + const parties = arbre && enChaine ? feuillesDuMot(jeu, cible, arbre) : morceauxDuMot(jeu, cible) return { sujet: {mot}, - consigne: morceaux === 2 ? 'Où ce mot se coupe-t-il ?' : `Coupez ce mot en ${morceaux} morceaux.`, + consigne: enChaine ? 'Coupez ce mot autant que vous le pouvez.' : morceaux === 2 ? 'Où ce mot se coupe-t-il ?' : `Coupez ce mot en ${morceaux} morceaux.`, choix: [], lecon: ( <> @@ -238,14 +244,19 @@ function Qcm({ q, jeu, d, envoyer, repondu }: QuestionProps) { ) } -/** Découper : le mot en grand ; survolée, une jointure écarte le mot ; touchée, elle le coupe. Le verdict tombe à la dernière coupe. */ -function Decoupe({ q, jeu, d, envoyer, repondu }: QuestionProps) { +/** + * Découper : le mot en grand ; survolée, une jointure écarte le mot ; touchée, elle le coupe. + * Chaque coupe part au serveur (ADR 0084) : juste, le mot reste ouvert tant qu'un morceau se coupe ; + * fausse, la question s'arrête ; la dernière coupe juste l'achève. + */ +export function Decoupe({ q, jeu, d, envoyer, repondu }: QuestionProps) { const question = useMemo(() => preparer(q, jeu, d), [q, jeu, d]) const unite = q.cartes[0] ?? '' - const attendu = d.mots.get(unite)?.coupes ?? [] + const arbre = arbreDuMot(d, unite) const lettres = Array.from(motDe(jeu, unite)) const [coupes, setCoupes] = useState([]) - const fini = coupes.length > 0 && coupes.length === attendu.length + const verdict = arbre && coupes.length > 0 ? jugerCoupes(arbre, coupes) : 'continue' + const fini = verdict !== 'continue' // Invite : tant que le joueur n'a pas touché au mot, il s'ouvre doucement, jointure après jointure. const [invite, setInvite] = useState(() => !mouvementReduit()) const [demo, setDemo] = useState(null) @@ -265,17 +276,19 @@ function Decoupe({ q, jeu, d, envoyer, repondu }: QuestionProps) { } }, [invite, coupes.length, n]) const couper = (p: number) => { - if (fini) return - const suite = coupes.includes(p) ? coupes.filter((c) => c !== p) : [...coupes, p].sort((a, b) => a - b) + // Une coupe posée est envoyée : elle ne se reprend pas. + if (fini || !arbre || coupes.includes(p)) return + const suite = [...coupes, p] setCoupes(suite) - if (suite.length !== attendu.length) return - envoyer('decouper', unite, suite) - repondu() + // Une commande par coupe, avec toutes les coupes posées ; seule celle qui clôt la question compte. + envoyer('decouper', unite, [...suite].sort((a, b) => a - b)) + if (jugerCoupes(arbre, suite) !== 'continue') repondu() } // Validé : le mot devient des briques. Justes, elles prennent leur vraie nature ; sinon préfixe, mot, suffixe selon leur place. - const juste = fini && juger(d, 'decouper', unite, coupes) === true - const bornes = [0, ...coupes, lettres.length] - const reels = morceauxDuMot(jeu, unite) + const juste = verdict === 'termine' + const triees = [...coupes].sort((a, b) => a - b) + const bornes = [0, ...triees, lettres.length] + const reels = arbre && juste ? feuillesDuMot(jeu, unite, arbre) : [] const morceaux = bornes.slice(1).map((fin, i, tous) => { const nature = (juste && reels.length === tous.length ? reels[i]?.nature : undefined) ?? (i === 0 ? 'prefixe' : i === tous.length - 1 ? 'suffixe' : 'mot') const texte = lettres.slice(bornes[i], fin).join('') @@ -311,6 +324,7 @@ function Decoupe({ q, jeu, d, envoyer, repondu }: QuestionProps) { className={`rico-coupe ${coupes.includes(k) ? 'coupee' : demo === k ? 'demo' : ''}`} aria-label={`Couper entre « ${lettres.slice(0, k).join('')} » et « ${lettres.slice(k).join('')} »`} aria-pressed={coupes.includes(k)} + disabled={coupes.includes(k)} onClick={() => couper(k)} /> )} @@ -327,6 +341,12 @@ function Decoupe({ q, jeu, d, envoyer, repondu }: QuestionProps) {
{question.lecon}
)} + {!fini && coupes.length > 0 && ( + <> + Bien vu. + Un morceau se coupe encore. + + )} ) @@ -641,7 +661,11 @@ export function Ricochets({ jeu, etat, enLigne, seance, repondre, entrees, lireM const envoyer = (format: FormatRicochet, unite: string, reponse: ReponseJoueur) => { const envoi = repondre(format, unite, reponse) envois.current.push(envoi) - void envoi.then((r) => setReponses((anciennes) => new Map(anciennes).set(unite, r))) + // Une coupe juste qui laisse la question ouverte n'est pas une réponse : seule la dernière compte. + void envoi.then((r) => { + if (!('erreur' in r) && r.suite === 'continue') return + setReponses((anciennes) => new Map(anciennes).set(unite, r)) + }) } if (!jeu || !d) return

Le catalogue se charge…

diff --git a/web/src/contrat/api.d.ts b/web/src/contrat/api.d.ts index cc342a5..a703c08 100644 --- a/web/src/contrat/api.d.ts +++ b/web/src/contrat/api.d.ts @@ -170,7 +170,9 @@ export interface paths { * Répond à une question de ricochet (ADR 0044). Le serveur juge sur les * identifiants, applique la règle de maîtrise et compte la réponse parmi les * cinq du jour. Une carte pas à revoir renvoie son résultat sans changer de niveau. - * Rien n'est consommé : ni exemplaire, ni énergie, ni encre. + * Rien n'est consommé : ni exemplaire, ni énergie, ni encre. Une coupe juste de + * *découper* qui laisse des coupes à poser (`suite = continue`, ADR 0084) ne + * modifie rien : seule la réponse qui clôt la question est une commande. */ post: operations["repondre_ricochet"]; delete?: never; @@ -889,6 +891,7 @@ export interface components { classe_litteral?: components["schemas"]["IdCatalogue"] | null; /** @description Positions de coupe de la composition préférée ; vide : le mot ne se découpe pas. */ coupes?: number[]; + decoupage?: components["schemas"]["Noeud"] | null; id: components["schemas"]["IdCatalogue"]; }; /** @enum {string} */ @@ -902,6 +905,20 @@ export interface components { * @enum {string} */ NaturePli: "prefixe" | "suffixe" | "mot"; + /** @description Un morceau du mot et ses coupes justes. */ + Noeud: { + /** @description Positions des coupes de ce morceau, dans le mot entier. */ + coupes: number[]; + /** Format: int32 */ + debut: number; + /** + * Format: int32 + * @description Fin exclue. + */ + fin: number; + /** @description Sous-nœuds : les parties qui se coupent à leur tour. */ + parties?: components["schemas"]["Noeud"][]; + }; OuverturePli: { brique_id: components["schemas"]["IdCatalogue"]; /** @@ -1049,8 +1066,16 @@ export interface components { carte: components["schemas"]["CarteRicochet"]; /** @description État confirmé après la commande. */ etat: components["schemas"]["Etat"]; - /** @description Vrai si la réponse est la bonne, que la carte ait été à revoir ou non. */ + /** + * @description Vrai si la réponse est la bonne, que la carte ait été à revoir ou non. + * Pour une coupe de *découper* qui laisse la question ouverte : vrai. + */ juste: boolean; + /** + * @description `continue` : *découper* a encore des coupes à poser (ADR 0084), rien n'est + * écrit ni compté. Absent des anciennes réponses : `terminee`. + */ + suite?: components["schemas"]["SuiteRicochet"]; }; ReponseSablier: { /** @description État confirmé après la commande. */ @@ -1175,6 +1200,11 @@ export interface components { }; /** @enum {string} */ StatutSante: "ok" | "degrade"; + /** + * @description Où en est la question après la réponse. + * @enum {string} + */ + SuiteRicochet: "terminee" | "continue"; Transformation: { id: components["schemas"]["IdCatalogue"]; libelles: components["schemas"]["BTreeMap_LibelleTexte"]; diff --git a/web/src/services/ricochets.test.ts b/web/src/services/ricochets.test.ts index 105c727..df5ada4 100644 --- a/web/src/services/ricochets.test.ts +++ b/web/src/services/ricochets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { type DonneesRicochets, juger, retour } from './ricochets' +import { type DonneesRicochets, type NoeudCoupes, arbreDuMot, feuillesDuMot, juger, jugerCoupes, retour } from './ricochets' const d: DonneesRicochets = { briques: new Map([ @@ -36,6 +36,97 @@ describe('juger', () => { }) }) +// Mêmes cas que `etymo_domaine::decoupage` (crates/domaine/src/decoupage.rs) : le miroir juge comme le serveur. +describe('jugerCoupes', () => { + const feuille = (debut: number, fin: number, coupes: number[]): NoeudCoupes => ({ debut, fin, coupes, parties: [] }) + // étymologiste : étymologie · iste, puis étymo · logie. + const etymologiste: NoeudCoupes = { debut: 0, fin: 13, coupes: [10], parties: [feuille(0, 10, [5])] } + + it('un mot à un niveau', () => { + const geologie = feuille(0, 8, [3]) + expect(jugerCoupes(geologie, [])).toBe('continue') + expect(jugerCoupes(geologie, [3])).toBe('termine') + expect(jugerCoupes(geologie, [3, 3])).toBe('termine') + expect(jugerCoupes(geologie, [2])).toBe('faux') + expect(jugerCoupes(geologie, [3, 5])).toBe('faux') + }) + it('la chaîne se coupe niveau par niveau', () => { + expect(jugerCoupes(etymologiste, [10])).toBe('continue') + expect(jugerCoupes(etymologiste, [10, 5])).toBe('termine') + }) + it('le sous-niveau attend son morceau, l’ordre de l’ensemble posé n’importe pas', () => { + expect(jugerCoupes(etymologiste, [5])).toBe('faux') + expect(jugerCoupes(etymologiste, [5, 10])).toBe('termine') + }) + it('une coupe fausse, même après des justes, arrête la question', () => { + expect(jugerCoupes(etymologiste, [10, 7])).toBe('faux') + expect(jugerCoupes(etymologiste, [10, 5, 7])).toBe('faux') + expect(jugerCoupes(etymologiste, [0])).toBe('faux') + expect(jugerCoupes(etymologiste, [99])).toBe('faux') + }) + it('trois parties se coupent dans un ordre libre', () => { + const mot = feuille(0, 12, [3, 8]) + expect(jugerCoupes(mot, [8])).toBe('continue') + expect(jugerCoupes(mot, [3])).toBe('continue') + expect(jugerCoupes(mot, [8, 3])).toBe('termine') + }) + it('un sous-nœud décalé, partie finale', () => { + const mot: NoeudCoupes = { debut: 0, fin: 10, coupes: [4], parties: [feuille(4, 10, [7])] } + expect(jugerCoupes(mot, [7])).toBe('faux') + expect(jugerCoupes(mot, [4])).toBe('continue') + expect(jugerCoupes(mot, [4, 7])).toBe('termine') + }) + it('un mot publié à plat se juge sur son seul niveau', () => { + const plat = { ...d, mots: new Map([['unt_9', { classe: null, coupes: [5, 3, 5] }]]) } + const arbre = arbreDuMot(plat, 'unt_9') + expect(arbre?.coupes).toEqual([3, 5]) + expect(juger(plat, 'decouper', 'unt_9', [3, 5])).toBe(true) + expect(juger(plat, 'decouper', 'unt_9', [3])).toBeNull() + expect(juger(plat, 'decouper', 'unt_9', [4])).toBe(false) + }) + it('juger : vrai à la dernière coupe, nul tant qu’il en reste, faux dès une erreur', () => { + const chaine = { ...d, mots: new Map([['unt_1', { classe: null, coupes: [10], decoupage: etymologiste }]]) } + expect(juger(chaine, 'decouper', 'unt_1', [10])).toBeNull() + expect(juger(chaine, 'decouper', 'unt_1', [10, 5])).toBe(true) + expect(juger(chaine, 'decouper', 'unt_1', [10, 7])).toBe(false) + }) +}) + +describe('feuillesDuMot', () => { + it('sans composition connue, la nature vient de la place du morceau', () => { + const jeu = { cartes: new Map() } as never + const arbre: NoeudCoupes = { debut: 0, fin: 13, coupes: [10], parties: [{ debut: 0, fin: 10, coupes: [5] }] } + expect(feuillesDuMot(jeu, 'unt_1', arbre)).toEqual([ + { debut: 0, fin: 10, nature: 'prefixe' }, + { debut: 10, fin: 13, nature: 'suffixe' }, + ]) + }) +}) + +describe('feuillesDuMot avec les compositions', () => { + it('descend dans les parties qui se coupent à leur tour et prend la nature de chaque unité', () => { + const composition = (parties: [string, number, number][]) => ({ + preferee: true, + parties: parties.map(([unite_id, segment_debut, segment_fin], i) => ({ rang: i + 1, unite_id, segment_debut, segment_fin })), + }) + const jeu = { + cartes: new Map([ + ['unt_mot', { nature: 'mot', compositions: [composition([['unt_logie', 0, 10], ['unt_iste', 10, 13]])] }], + ['unt_logie', { nature: 'mot', compositions: [composition([['unt_etymo', 0, 5], ['unt_fin', 5, 10]])] }], + ['unt_etymo', { nature: 'prefixe' }], + ['unt_fin', { nature: 'suffixe' }], + ['unt_iste', { nature: 'suffixe' }], + ]), + } as never + const arbre: NoeudCoupes = { debut: 0, fin: 13, coupes: [10], parties: [{ debut: 0, fin: 10, coupes: [5] }] } + expect(feuillesDuMot(jeu, 'unt_mot', arbre)).toEqual([ + { debut: 0, fin: 5, nature: 'prefixe' }, + { debut: 5, fin: 10, nature: 'suffixe' }, + { debut: 10, fin: 13, nature: 'suffixe' }, + ]) + }) +}) + describe('retour', () => { const carte = (jours: number, maitrisee = false) => ({ niveau: 1, diff --git a/web/src/services/ricochets.ts b/web/src/services/ricochets.ts index edfe62f..6f76e6b 100644 --- a/web/src/services/ricochets.ts +++ b/web/src/services/ricochets.ts @@ -15,10 +15,21 @@ interface BriqueRicochet { classe: string } +/** Un morceau du mot et ses coupes justes (ADR 0084) : des positions dans le mot entier. */ +export interface NoeudCoupes { + debut: number + fin: number + coupes: number[] + parties?: NoeudCoupes[] +} + +/** Ce que valent les coupes posées : `faux` arrête la question, `termine` l'achève. */ +export type VerdictCoupes = 'faux' | 'continue' | 'termine' + export interface DonneesRicochets { briques: Map - /** Classe du sens littéral et coupes, par mot. */ - mots: Map + /** Classe du sens littéral, coupes de premier niveau et arbre des coupes (ADR 0084), par mot. */ + mots: Map /** Glose de chaque sens affiché d'une brique. */ gloses: Map } @@ -31,7 +42,7 @@ export function donneesRicochets(jeu: CatalogueJeu): DonneesRicochets { const glose = jeu.briques.get(b.id)?.glose if (glose) donnees.gloses.set(b.sens_id, glose) } - for (const m of manifeste.ricochets?.mots ?? []) donnees.mots.set(m.id, { classe: m.classe_litteral ?? null, coupes: m.coupes ?? [] }) + for (const m of manifeste.ricochets?.mots ?? []) donnees.mots.set(m.id, { classe: m.classe_litteral ?? null, coupes: m.coupes ?? [], decoupage: m.decoupage ?? null }) } return donnees } @@ -43,13 +54,50 @@ export function sensLitteral(jeu: CatalogueJeu, motId: string): string | null { return choisie?.libelles.fr?.sens_litteral ?? null } +/** Arbre à juger : le `decoupage` publié, ou à défaut les `coupes` du premier niveau (manifeste antérieur). */ +export function arbreDuMot(d: DonneesRicochets, unite: string): NoeudCoupes | null { + const mot = d.mots.get(unite) + if (!mot) return null + if (mot.decoupage) return mot.decoupage + return mot.coupes.length ? { debut: 0, fin: Number.POSITIVE_INFINITY, coupes: [...new Set(mot.coupes)].sort((a, b) => a - b) } : null +} + +function rassembler(n: NoeudCoupes, tous: NoeudCoupes[] = []): NoeudCoupes[] { + tous.push(n) + for (const p of n.parties ?? []) rassembler(p, tous) + return tous +} + +/** + * Juge l'ensemble des coupes posées, quel que soit leur ordre. Miroir de + * `etymo_domaine::decoupage::juger` : changez les deux ensemble, avec leurs tests. + * Une coupe est juste si un nœud la porte et si ses deux bords sont des bords du mot + * ou des coupes déjà posées. + */ +export function jugerCoupes(racine: NoeudCoupes, posees: readonly number[]): VerdictCoupes { + const noeuds = rassembler(racine) + const attendues = new Set(noeuds.flatMap((n) => n.coupes)) + const restantes = new Set(posees) + const placees = new Set() + const bordOk = (b: number) => b === racine.debut || b === racine.fin || placees.has(b) + for (;;) { + const juste = [...restantes].find((c) => noeuds.some((n) => n.coupes.includes(c) && bordOk(n.debut) && bordOk(n.fin))) + if (juste === undefined) break + restantes.delete(juste) + placees.add(juste) + } + if (restantes.size > 0) return 'faux' + return placees.size === attendues.size && [...attendues].every((c) => placees.has(c)) ? 'termine' : 'continue' +} + /** Verdict d'une réponse. `null` : la donnée manque, le client n'affiche pas de verdict. */ export function juger(d: DonneesRicochets, format: FormatRicochet, unite: string, reponse: ReponseJoueur): boolean | null { if (format === 'decouper') { - const attendu = d.mots.get(unite)?.coupes - if (!attendu?.length || !Array.isArray(reponse)) return null - const coupes = [...new Set(reponse)].sort((a, b) => a - b) - return coupes.length === attendu.length && coupes.every((c, i) => c === attendu[i]) + const arbre = arbreDuMot(d, unite) + if (!arbre || !Array.isArray(reponse)) return null + // Réussi seulement quand toutes les coupes sont posées ; une question encore ouverte n'a pas de verdict. + const verdict = jugerCoupes(arbre, reponse) + return verdict === 'termine' ? true : verdict === 'faux' ? false : null } if (typeof reponse !== 'string') return null if (format === 'mot_sens') { @@ -89,12 +137,31 @@ export function retour(carte: CarteRicochet, aujourdhui: Date = new Date()): str } /** Morceaux du mot dans sa composition préférée, aux couleurs de leurs briques (verdict de « découper »). */ -export function morceauxDuMot(jeu: CatalogueJeu, motId: string): { debut: number; fin: number; nature: BriqueJeu['nature'] }[] { +export function morceauxDuMot(jeu: CatalogueJeu, motId: string): { debut: number; fin: number; nature: BriqueJeu['nature']; unite: string }[] { const compositions = jeu.cartes.get(motId)?.compositions ?? [] const choisie = compositions.find((c) => c.preferee) ?? compositions[0] return [...(choisie?.parties ?? [])] .sort((a, b) => a.rang - b.rang) - .map((p) => ({ debut: p.segment_debut ?? -1, fin: p.segment_fin ?? -1, nature: jeu.cartes.get(p.unite_id)?.nature ?? 'mot' })) + .map((p) => ({ debut: p.segment_debut ?? -1, fin: p.segment_fin ?? -1, nature: jeu.cartes.get(p.unite_id)?.nature ?? 'mot', unite: p.unite_id })) +} + +/** + * Morceaux élémentaires d'un mot, une fois toutes les coupes de l'arbre posées : leur place + * dans le mot entier et leur nature (préfixe, mot, suffixe). La nature vient de la composition + * de chaque partie ; à défaut, de la place du morceau. + */ +export function feuillesDuMot(jeu: CatalogueJeu, motId: string, noeud: NoeudCoupes): { debut: number; fin: number; nature: BriqueJeu['nature'] }[] { + const bornes = [noeud.debut, ...[...noeud.coupes].sort((a, b) => a - b), noeud.fin] + const parties = morceauxDuMot(jeu, motId) + const alignees = parties.length === bornes.length - 1 + return bornes.slice(1).flatMap((fin, i) => { + const debut = bornes[i] as number + const sous = noeud.parties?.find((p) => p.debut === debut) + const partie = alignees ? parties[i] : undefined + if (sous && partie) return feuillesDuMot(jeu, partie.unite, sous) + const nature = partie?.nature ?? (i === 0 ? 'prefixe' : i === bornes.length - 2 ? 'suffixe' : 'mot') + return [{ debut, fin, nature }] + }) } /** Briques de la première recette du mot, dans l'ordre : les morceaux de la composition sont des unités, pas des briques. */