diff --git a/.sqlx/query-03da70796777c07ea1b77719e222934da86f159c7a8d3fde4b8eb77f405a6d24.json b/.sqlx/query-03da70796777c07ea1b77719e222934da86f159c7a8d3fde4b8eb77f405a6d24.json
new file mode 100644
index 0000000..726326e
--- /dev/null
+++ b/.sqlx/query-03da70796777c07ea1b77719e222934da86f159c7a8d3fde4b8eb77f405a6d24.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM essai WHERE joueur_id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "03da70796777c07ea1b77719e222934da86f159c7a8d3fde4b8eb77f405a6d24"
+}
diff --git a/.sqlx/query-111159fe44528e6218f432dfb69d762ea1368975f67d4272b08d5880614a3153.json b/.sqlx/query-111159fe44528e6218f432dfb69d762ea1368975f67d4272b08d5880614a3153.json
new file mode 100644
index 0000000..16a2558
--- /dev/null
+++ b/.sqlx/query-111159fe44528e6218f432dfb69d762ea1368975f67d4272b08d5880614a3153.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM essai WHERE joueur_id = $1 AND fascicule_juge <> $2",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "111159fe44528e6218f432dfb69d762ea1368975f67d4272b08d5880614a3153"
+}
diff --git a/.sqlx/query-f01e57df3a38d5466cb93192af44ac9462b9829d9d706291acf249176566c52f.json b/.sqlx/query-57fa055950224e18d3a05435769982967e65e57e0f2b61587d6e9489df8cc7b3.json
similarity index 69%
rename from .sqlx/query-f01e57df3a38d5466cb93192af44ac9462b9829d9d706291acf249176566c52f.json
rename to .sqlx/query-57fa055950224e18d3a05435769982967e65e57e0f2b61587d6e9489df8cc7b3.json
index cf7317b..ccc0ecd 100644
--- a/.sqlx/query-f01e57df3a38d5466cb93192af44ac9462b9829d9d706291acf249176566c52f.json
+++ b/.sqlx/query-57fa055950224e18d3a05435769982967e65e57e0f2b61587d6e9489df8cc7b3.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT jsonb_build_object(\n 'joueur', to_jsonb(j),\n 'compte', (SELECT to_jsonb(c) - 'joueur_id' FROM compte c WHERE c.joueur_id = j.id),\n 'appareils', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'empreinte_secret' - 'joueur_id'), '[]') FROM appareil t WHERE t.joueur_id = j.id),\n 'reglages', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM reglage t WHERE t.joueur_id = j.id),\n 'commandes', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM commande t WHERE t.joueur_id = j.id),\n 'exemplaires', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM exemplaire t WHERE t.joueur_id = j.id),\n 'decouvertes', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM decouverte t WHERE t.joueur_id = j.id),\n 'mouvements', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM mouvement t WHERE t.joueur_id = j.id),\n 'graines_accordees', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM graine_accordee t WHERE t.joueur_id = j.id),\n 'energie', (SELECT to_jsonb(t) - 'joueur_id' FROM energie t WHERE t.joueur_id = j.id),\n 'encre', (SELECT to_jsonb(t) - 'joueur_id' FROM encre t WHERE t.joueur_id = j.id),\n 'compteurs_pli', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM compteur_pli t WHERE t.joueur_id = j.id),\n 'ouvertures_pli', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM ouverture_pli t WHERE t.joueur_id = j.id),\n 'sablier', (SELECT to_jsonb(t) - 'joueur_id' FROM sablier t WHERE t.joueur_id = j.id),\n 'indices', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM indice t WHERE t.joueur_id = j.id),\n 'jalons_atteints', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM jalon_atteint t WHERE t.joueur_id = j.id),\n 'ricochets', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM ricochet t WHERE t.joueur_id = j.id),\n 'ricochets_du_jour', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM ricochet_jour t WHERE t.joueur_id = j.id),\n 'paliers_recuperes', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM palier_recupere t WHERE t.joueur_id = j.id),\n 'quetes_du_jour', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM quete_jour t WHERE t.joueur_id = j.id),\n 'tampons', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM tampon t WHERE t.joueur_id = j.id),\n 'cartes_lecteur_recuperees', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM carte_lecteur_recuperee t WHERE t.joueur_id = j.id),\n 'cadeau_recupere', (SELECT to_jsonb(t) - 'joueur_id' FROM cadeau_recupere t WHERE t.joueur_id = j.id),\n 'quetes_initiales', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM quete_initiale t WHERE t.joueur_id = j.id),\n 'fascicules_debloques', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM fascicule_debloque t WHERE t.joueur_id = j.id)\n ) AS \"donnees!: Value\"\n FROM compte c JOIN joueur j ON j.id = c.joueur_id WHERE c.empreinte_courriel = $1",
+ "query": "SELECT jsonb_build_object(\n 'joueur', to_jsonb(j),\n 'compte', (SELECT to_jsonb(c) - 'joueur_id' FROM compte c WHERE c.joueur_id = j.id),\n 'appareils', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'empreinte_secret' - 'joueur_id'), '[]') FROM appareil t WHERE t.joueur_id = j.id),\n 'reglages', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM reglage t WHERE t.joueur_id = j.id),\n 'commandes', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM commande t WHERE t.joueur_id = j.id),\n 'exemplaires', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM exemplaire t WHERE t.joueur_id = j.id),\n 'decouvertes', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM decouverte t WHERE t.joueur_id = j.id),\n 'mouvements', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM mouvement t WHERE t.joueur_id = j.id),\n 'graines_accordees', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM graine_accordee t WHERE t.joueur_id = j.id),\n 'energie', (SELECT to_jsonb(t) - 'joueur_id' FROM energie t WHERE t.joueur_id = j.id),\n 'encre', (SELECT to_jsonb(t) - 'joueur_id' FROM encre t WHERE t.joueur_id = j.id),\n 'compteurs_pli', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM compteur_pli t WHERE t.joueur_id = j.id),\n 'ouvertures_pli', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM ouverture_pli t WHERE t.joueur_id = j.id),\n 'sablier', (SELECT to_jsonb(t) - 'joueur_id' FROM sablier t WHERE t.joueur_id = j.id),\n 'indices', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM indice t WHERE t.joueur_id = j.id),\n 'jalons_atteints', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM jalon_atteint t WHERE t.joueur_id = j.id),\n 'ricochets', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM ricochet t WHERE t.joueur_id = j.id),\n 'ricochets_du_jour', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM ricochet_jour t WHERE t.joueur_id = j.id),\n 'paliers_recuperes', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM palier_recupere t WHERE t.joueur_id = j.id),\n 'essais', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM essai t WHERE t.joueur_id = j.id),\n 'quetes_du_jour', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM quete_jour t WHERE t.joueur_id = j.id),\n 'tampons', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM tampon t WHERE t.joueur_id = j.id),\n 'cartes_lecteur_recuperees', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM carte_lecteur_recuperee t WHERE t.joueur_id = j.id),\n 'cadeau_recupere', (SELECT to_jsonb(t) - 'joueur_id' FROM cadeau_recupere t WHERE t.joueur_id = j.id),\n 'quetes_initiales', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM quete_initiale t WHERE t.joueur_id = j.id),\n 'fascicules_debloques', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM fascicule_debloque t WHERE t.joueur_id = j.id)\n ) AS \"donnees!: Value\"\n FROM compte c JOIN joueur j ON j.id = c.joueur_id WHERE c.empreinte_courriel = $1",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
null
]
},
- "hash": "f01e57df3a38d5466cb93192af44ac9462b9829d9d706291acf249176566c52f"
+ "hash": "57fa055950224e18d3a05435769982967e65e57e0f2b61587d6e9489df8cc7b3"
}
diff --git a/.sqlx/query-9cf046387978435ee9dc64402be0ea0cde5db1682a44995936bab468a15aa684.json b/.sqlx/query-9cf046387978435ee9dc64402be0ea0cde5db1682a44995936bab468a15aa684.json
new file mode 100644
index 0000000..2489896
--- /dev/null
+++ b/.sqlx/query-9cf046387978435ee9dc64402be0ea0cde5db1682a44995936bab468a15aa684.json
@@ -0,0 +1,18 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO essai (joueur_id, gauche, droite, fascicule_juge)\n SELECT $1, $2, $3, $4\n WHERE (SELECT count(*) FROM exemplaire WHERE joueur_id = $1 AND brique_id IN ($2, $3)) = (CASE WHEN $2 = $3 THEN 1 ELSE 2 END)\n AND (SELECT count(*) FROM essai WHERE joueur_id = $1) < $5\n ON CONFLICT DO NOTHING",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Text",
+ "Text",
+ "Text",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "9cf046387978435ee9dc64402be0ea0cde5db1682a44995936bab468a15aa684"
+}
diff --git a/.sqlx/query-d9f5a3f049ee3b351c5f56fa1ebe8484761a7607697f86868bb9442faddf784a.json b/.sqlx/query-d9f5a3f049ee3b351c5f56fa1ebe8484761a7607697f86868bb9442faddf784a.json
new file mode 100644
index 0000000..f36174d
--- /dev/null
+++ b/.sqlx/query-d9f5a3f049ee3b351c5f56fa1ebe8484761a7607697f86868bb9442faddf784a.json
@@ -0,0 +1,29 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT gauche, droite FROM essai WHERE joueur_id = $1 AND fascicule_juge = $2 ORDER BY gauche, droite",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "gauche",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "droite",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "d9f5a3f049ee3b351c5f56fa1ebe8484761a7607697f86868bb9442faddf784a"
+}
diff --git a/contrat/openapi.json b/contrat/openapi.json
index 11dbf8a..bbd353c 100644
--- a/contrat/openapi.json
+++ b/contrat/openapi.json
@@ -3187,6 +3187,23 @@
},
"additionalProperties": false
},
+ "Essai": {
+ "type": "object",
+ "description": "Une paire essayée sans succès (échec ou « presque ») : ni issue, ni date.",
+ "required": [
+ "gauche",
+ "droite"
+ ],
+ "properties": {
+ "droite": {
+ "$ref": "#/components/schemas/IdCatalogue"
+ },
+ "gauche": {
+ "$ref": "#/components/schemas/IdCatalogue"
+ }
+ },
+ "additionalProperties": false
+ },
"Etat": {
"type": "object",
"description": "Projection de l'état confirmé du joueur : des identifiants et des nombres,\njamais de texte du catalogue (ADR 0027).",
@@ -3257,6 +3274,13 @@
"$ref": "#/components/schemas/EnergieEtat",
"description": "Charges de pli, à l'horloge du serveur (ADR 0020)."
},
+ "essais": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Essai"
+ },
+ "description": "Paires déjà essayées sans succès depuis la dernière parution (App#89),\ndans l'ordre des cases, triées. Vide avant ce champ ou après une parution."
+ },
"fascicules_debloques": {
"type": "array",
"items": {
diff --git a/crates/api/src/export.rs b/crates/api/src/export.rs
index 7b641e4..3161848 100644
--- a/crates/api/src/export.rs
+++ b/crates/api/src/export.rs
@@ -38,6 +38,7 @@ pub async fn exporter(base: &PgPool, courriel: &Courriel, adresse: &str) -> anyh
'ricochets', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM ricochet t WHERE t.joueur_id = j.id),
'ricochets_du_jour', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM ricochet_jour t WHERE t.joueur_id = j.id),
'paliers_recuperes', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM palier_recupere t WHERE t.joueur_id = j.id),
+ 'essais', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM essai t WHERE t.joueur_id = j.id),
'quetes_du_jour', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM quete_jour t WHERE t.joueur_id = j.id),
'tampons', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM tampon t WHERE t.joueur_id = j.id),
'cartes_lecteur_recuperees', (SELECT coalesce(jsonb_agg(to_jsonb(t) - 'joueur_id'), '[]') FROM carte_lecteur_recuperee t WHERE t.joueur_id = j.id),
diff --git a/crates/api/src/progression.rs b/crates/api/src/progression.rs
index e91dd94..9c2b2f1 100644
--- a/crates/api/src/progression.rs
+++ b/crates/api/src/progression.rs
@@ -7,8 +7,8 @@ use std::collections::{HashMap, HashSet};
use chrono::{DateTime, TimeDelta, Utc};
use etymo_catalogue::Jeu;
use etymo_contrat::{
- CadeauEtat, CompteEtat, CompteurPli, Decouverte, EnergieEtat, Etat, Exemplaires,
- FasciculeDebloque, IndiceObtenu, OuverturePli, ProgressionQuete, SabliersEtat,
+ CadeauEtat, CompteEtat, CompteurPli, Decouverte, EnergieEtat, Essai, Etat, Exemplaires,
+ FasciculeDebloque, IndiceObtenu, IssueFusion, OuverturePli, ProgressionQuete, SabliersEtat,
};
use etymo_domaine::IdCatalogue;
use etymo_domaine::carte_de_lecteur;
@@ -144,9 +144,82 @@ pub async fn etat(
quetes_initiales: quetes_initiales::etat(&mut *tx, joueur, jeu, maintenant).await?,
fascicules_debloques: lire_fascicules_debloques(&mut *tx, joueur).await?,
compte: compte_etat(&mut *tx, joueur).await?,
+ essais: lire_essais(&mut *tx, joueur, jeu).await?,
})
}
+/// Plafond de la mémoire des essais, par joueur (App#89) : au-delà, un essai n'est plus noté.
+pub const PLAFOND_ESSAIS: i64 = 3000;
+
+/// Dernier fascicule paru : la mémoire des essais ne vaut que jusqu'à la parution suivante.
+fn fascicule_juge(jeu: &Jeu) -> Option<&IdCatalogue> {
+ jeu.chaine.last().map(|m| &m.fascicule_id)
+}
+
+/// Paires essayées depuis la dernière parution ; les plus anciennes ne se lisent plus.
+async fn lire_essais(
+ tx: &mut PgConnection,
+ joueur: Uuid,
+ jeu: Option<&Jeu>,
+) -> Result, ErreurApi> {
+ let Some(fascicule) = jeu.and_then(fascicule_juge) else {
+ return Ok(Vec::new());
+ };
+ sqlx::query!(
+ "SELECT gauche, droite FROM essai WHERE joueur_id = $1 AND fascicule_juge = $2 ORDER BY gauche, droite",
+ joueur,
+ fascicule.as_str()
+ )
+ .fetch_all(tx)
+ .await?
+ .into_iter()
+ .map(|l| Ok(Essai { gauche: id(&l.gauche)?, droite: id(&l.droite)? }))
+ .collect()
+}
+
+/// Note un essai sans succès (App#89) : un échec ou un « presque », si le
+/// joueur connaît les deux briques. Renvoie vrai pour un essai à noter.
+/// Une parution oublie en bloc les essais d'avant : de vieilles paires peuvent
+/// devenir des mots, et une purge paire par paire désignerait les familles du
+/// nouveau fascicule. Sans ligne de commande ni changement de version : la
+/// mémoire est une aide, pas un état de jeu.
+pub async fn noter_essai(
+ tx: &mut PgConnection,
+ joueur: Uuid,
+ jeu: &Jeu,
+ briques: &[IdCatalogue],
+ issue: &IssueFusion,
+) -> Result {
+ if !matches!(issue, IssueFusion::Echec | IssueFusion::Presque { .. }) {
+ return Ok(false);
+ }
+ let ([gauche, droite], Some(fascicule)) = (briques, fascicule_juge(jeu)) else {
+ return Ok(true);
+ };
+ sqlx::query!(
+ "DELETE FROM essai WHERE joueur_id = $1 AND fascicule_juge <> $2",
+ joueur,
+ fascicule.as_str()
+ )
+ .execute(&mut *tx)
+ .await?;
+ sqlx::query!(
+ "INSERT INTO essai (joueur_id, gauche, droite, fascicule_juge)
+ SELECT $1, $2, $3, $4
+ WHERE (SELECT count(*) FROM exemplaire WHERE joueur_id = $1 AND brique_id IN ($2, $3)) = (CASE WHEN $2 = $3 THEN 1 ELSE 2 END)
+ AND (SELECT count(*) FROM essai WHERE joueur_id = $1) < $5
+ ON CONFLICT DO NOTHING",
+ joueur,
+ gauche.as_str(),
+ droite.as_str(),
+ fascicule.as_str(),
+ PLAFOND_ESSAIS
+ )
+ .execute(&mut *tx)
+ .await?;
+ Ok(true)
+}
+
/// Compte du joueur, absent pour une partie invitée (ADR 0070).
pub(crate) async fn compte_etat(
tx: &mut PgConnection,
@@ -1578,6 +1651,9 @@ pub async fn reinitialiser(tx: &mut PgConnection, joueur: Uuid) -> Result<(), Er
sqlx::query!("DELETE FROM palier_recupere WHERE joueur_id = $1", joueur)
.execute(&mut *tx)
.await?;
+ sqlx::query!("DELETE FROM essai WHERE joueur_id = $1", joueur)
+ .execute(&mut *tx)
+ .await?;
sqlx::query!("DELETE FROM commande WHERE joueur_id = $1", joueur)
.execute(&mut *tx)
.await?;
diff --git a/crates/api/src/routes.rs b/crates/api/src/routes.rs
index 1074ea3..f6943b1 100644
--- a/crates/api/src/routes.rs
+++ b/crates/api/src/routes.rs
@@ -344,6 +344,8 @@ pub async fn fusion(
Issue::Presque { motif } => IssueFusion::Presque { motif },
Issue::Echec => IssueFusion::Echec,
};
+ // Mémoire des essais (App#89) : un échec ou un « presque » se note, avant l'état renvoyé.
+ let essai = progression::noter_essai(&mut tx, joueur, jeu, &demande.briques, &issue).await?;
let reponse = ReponseFusion {
etat: etat_courant(&app, &mut tx, joueur).await?,
issue,
@@ -362,8 +364,11 @@ pub async fn fusion(
.execute(&mut *tx)
.await?;
tx.commit().await?;
+ } else if essai {
+ // Ni commande ni version : l'essai noté se rejoue sans effet (ON CONFLICT DO NOTHING).
+ tx.commit().await?;
} else {
- // Une proposition qui ne modifie rien ne laisse aucune ligne (modèle de données §7).
+ // Une autre proposition qui ne modifie rien ne laisse aucune ligne (modèle de données §7).
tx.rollback().await?;
}
Ok(Json(reponse))
diff --git a/crates/api/tests/fusion.rs b/crates/api/tests/fusion.rs
index 8365fc0..a901869 100644
--- a/crates/api/tests/fusion.rs
+++ b/crates/api/tests/fusion.rs
@@ -143,10 +143,147 @@ async fn les_essais_rates_ne_coutent_rien(base: PgPool) {
appeler(&banc.routeur, post_fusion(&jeton, &fusion(&etat, &briques))).await;
assert_eq!(statut, StatusCode::OK, "{reponse}");
assert_eq!(reponse["issue"], issue, "{briques:?}");
- assert_eq!(reponse["etat"], etat, "rien ne change");
+ let mut apres = reponse["etat"].clone();
+ apres["essais"] = etat["essais"].clone();
+ assert_eq!(apres, etat, "rien ne change, hors la mémoire des essais");
}
}
+fn essais(etat: &Value) -> Vec<(String, String)> {
+ etat["essais"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|e| {
+ (
+ e["gauche"].as_str().unwrap().to_owned(),
+ e["droite"].as_str().unwrap().to_owned(),
+ )
+ })
+ .collect()
+}
+
+#[sqlx::test(migrations = "../../migrations")]
+async fn la_memoire_des_essais_note_les_echecs_et_les_presque(base: PgPool) {
+ let banc = banc(base.clone(), Limiteurs::default()).await;
+ let jeton = session(&banc.routeur).await;
+ let (_, _, etat) = appeler(&banc.routeur, get_etat(&jeton)).await;
+ assert_eq!(essais(&etat), vec![]);
+
+ // Un « presque », un échec, et une exclusion (que le client calcule seul) ; bio- n'est pas en réserve.
+ for briques in [[LOGIE, GEO], [GEO, GEO], [BIO, GEO], [BIO, LOGIE]] {
+ let (statut, _, _) =
+ appeler(&banc.routeur, post_fusion(&jeton, &fusion(&etat, &briques))).await;
+ assert_eq!(statut, StatusCode::OK);
+ }
+ // Rejoué, un essai ne se note qu'une fois ; ni commande, ni version.
+ appeler(
+ &banc.routeur,
+ post_fusion(&jeton, &fusion(&etat, &[GEO, GEO])),
+ )
+ .await;
+ let (_, _, apres) = appeler(&banc.routeur, get_etat(&jeton)).await;
+ assert_eq!(
+ essais(&apres),
+ vec![
+ (GEO.to_owned(), GEO.to_owned()),
+ (LOGIE.to_owned(), GEO.to_owned())
+ ]
+ );
+ assert_eq!(apres["version_etat"], etat["version_etat"]);
+ let commandes: i64 = sqlx::query_scalar("SELECT count(*) FROM commande")
+ .fetch_one(&base)
+ .await
+ .unwrap();
+ assert_eq!(commandes, 0);
+ // Rien d'autre que deux briques et le fascicule : ni issue, ni date.
+ let colonnes: Vec = sqlx::query_scalar(
+ "SELECT column_name::text FROM information_schema.columns WHERE table_name = 'essai' ORDER BY ordinal_position",
+ )
+ .fetch_all(&base)
+ .await
+ .unwrap();
+ assert_eq!(
+ colonnes,
+ ["joueur_id", "gauche", "droite", "fascicule_juge"]
+ );
+
+ // Une découverte ne se note pas.
+ appeler(
+ &banc.routeur,
+ post_fusion(&jeton, &fusion(&apres, &[GEO, LOGIE])),
+ )
+ .await;
+ let (_, _, apres) = appeler(&banc.routeur, get_etat(&jeton)).await;
+ assert_eq!(essais(&apres).len(), 2);
+}
+
+#[sqlx::test(migrations = "../../migrations")]
+async fn une_parution_oublie_tous_les_essais(base: PgPool) {
+ let banc = banc(base.clone(), Limiteurs::default()).await;
+ let jeton = session(&banc.routeur).await;
+ let (_, _, etat) = appeler(&banc.routeur, get_etat(&jeton)).await;
+ appeler(
+ &banc.routeur,
+ post_fusion(&jeton, &fusion(&etat, &[GEO, GEO])),
+ )
+ .await;
+ // Simule une parution : l'essai a été jugé sous un fascicule plus ancien.
+ sqlx::query("UPDATE essai SET fascicule_juge = 'fsc_00000999'")
+ .execute(&base)
+ .await
+ .unwrap();
+ let (_, _, apres) = appeler(&banc.routeur, get_etat(&jeton)).await;
+ assert_eq!(essais(&apres), vec![]);
+ // Le prochain essai efface en bloc les lignes d'avant.
+ appeler(
+ &banc.routeur,
+ post_fusion(&jeton, &fusion(&etat, &[LOGIE, GEO])),
+ )
+ .await;
+ let lignes: i64 = sqlx::query_scalar("SELECT count(*) FROM essai")
+ .fetch_one(&base)
+ .await
+ .unwrap();
+ assert_eq!(lignes, 1);
+}
+
+#[sqlx::test(migrations = "../../migrations")]
+async fn la_memoire_des_essais_est_plafonnee(base: PgPool) {
+ let banc = banc(base.clone(), Limiteurs::default()).await;
+ let jeton = session(&banc.routeur).await;
+ let (_, _, etat) = appeler(&banc.routeur, get_etat(&jeton)).await;
+ appeler(
+ &banc.routeur,
+ post_fusion(&jeton, &fusion(&etat, &[GEO, GEO])),
+ )
+ .await;
+ let fascicule: String = sqlx::query_scalar("SELECT fascicule_juge FROM essai")
+ .fetch_one(&base)
+ .await
+ .unwrap();
+ // Remplit la mémoire jusqu'au plafond avec des paires fictives.
+ sqlx::query(
+ "INSERT INTO essai (joueur_id, gauche, droite, fascicule_juge)
+ SELECT joueur_id, 'brq_' || lpad(n::text, 8, '0'), 'brq_00000000', $1 FROM essai, generate_series(1, $2) n",
+ )
+ .bind(&fascicule)
+ .bind(i32::try_from(etymo_api::progression::PLAFOND_ESSAIS).unwrap() - 1)
+ .execute(&base)
+ .await
+ .unwrap();
+ appeler(
+ &banc.routeur,
+ post_fusion(&jeton, &fusion(&etat, &[LOGIE, GEO])),
+ )
+ .await;
+ let lignes: i64 = sqlx::query_scalar("SELECT count(*) FROM essai")
+ .fetch_one(&base)
+ .await
+ .unwrap();
+ assert_eq!(lignes, etymo_api::progression::PLAFOND_ESSAIS);
+}
+
#[sqlx::test(migrations = "../../migrations")]
async fn une_decouverte_exige_un_etat_et_un_catalogue_a_jour(base: PgPool) {
let banc = banc(base, Limiteurs::default()).await;
diff --git a/crates/contrat/src/lib.rs b/crates/contrat/src/lib.rs
index dd02ad6..4bec68b 100644
--- a/crates/contrat/src/lib.rs
+++ b/crates/contrat/src/lib.rs
@@ -120,6 +120,18 @@ pub struct Etat {
/// Compte facultatif (ADR 0070) ; absent pour une partie invitée.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compte: Option,
+ /// Paires déjà essayées sans succès depuis la dernière parution (App#89),
+ /// dans l'ordre des cases, triées. Vide avant ce champ ou après une parution.
+ #[serde(default)]
+ pub essais: Vec,
+}
+
+/// Une paire essayée sans succès (échec ou « presque ») : ni issue, ni date.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(deny_unknown_fields)]
+pub struct Essai {
+ pub gauche: IdCatalogue,
+ pub droite: IdCatalogue,
}
/// Compte du joueur (ADR 0070). L'adresse n'y figure jamais : le serveur ne
diff --git a/docs/roadmap.md b/docs/roadmap.md
index f8d0517..bee7a58 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -58,6 +58,7 @@ Hors MVP, comme le prévoit le game-design : paiements, fonctions sociales, cont
| Coque de l’application (I-01) : quatre destinations Table, Plis, Codex, Réglages (ordre changé le 28 septembre) ; barre d’onglets sur mobile, rail au-delà de 1 100 px ; compteur Corail des nouveautés | fait |
| Conformité aux maquettes I-09 à I-13 : en-tête d’écran (mots trouvés, accès au codex) ; réserve avec recherche, filtres et tri par briques récentes ; un clic ou un toucher pose la brique, glisser-déposer, flèches et Suppr sur les cases ; table de deux cases (jamais plus de deux briques à la fois) ; retours en ligne (« presque » avec Inverser, rappel du mot déjà découvert, refus doux par une secousse amortie et découverte (briques qui s’emboîtent en un mot), voir « Animations retenues ») ; fiche en trois temps en dialogue, plein écran sur mobile ; état « Aucun mot possible » ; contrôle `visual-identity-check` sans écart | fait |
| Réserve sans découverte possible (I-13) : état vide qui mène aux plis | fait |
+| Mémoire des essais (App#89) : une brique posée, la réserve range en fin de liste, grisées (Sable, Encre secondaire, sans ombre, toujours jouables), les briques déjà essayées avec elle dans cet ordre (« aucune recette », « presque : inversez », « au codex »), avec glissement de 240 ms ; table `essai` (deux briques et le dernier fascicule paru, ni issue ni date) écrite par `POST /fusion` pour un échec ou un « presque » si le joueur connaît les deux briques, plafond de 3 000 lignes, oubli en bloc à la parution suivante ; `Etat.essais`, export et réinitialisation | fait en local ; ADR à écrire dans Design (revient sur l’ADR 0027 et le modèle de données § 7) |
| Réserve en modale (issue #9) : cachée par défaut ; toucher une case vide ou le bouton « Réserve » l’ouvre, la brique choisie se pose sur cette case (ou la plus à gauche) ; focus piégé, `Échap`, retour du focus | fait ; maquette I-09 du dossier design à aligner |
| Réserve fluide : la modale reste ouverte tant qu’une case est libre et se ferme d’elle-même table complète, focus sur « Fusionner » ; table en petit dans la modale (case visée, brique qui s’y pose, retrait d’un toucher), consigne « Choisissez la 1ʳᵉ / 2ᵉ brique » et annonce pour les lecteurs d’écran, pastille ×n qui rebondit ; les briques tombent sur la table une fois le voile levé | fait ; maquette I-09 du dossier design à aligner |
| Réglages du MVP en cours (I-24) : apparence (système, atelier, nocturne), animations réduites, profil, licences | fait |
diff --git a/migrations/20261023000001_essais.sql b/migrations/20261023000001_essais.sql
new file mode 100644
index 0000000..b6dcae8
--- /dev/null
+++ b/migrations/20261023000001_essais.sql
@@ -0,0 +1,13 @@
+-- Mémoire des essais (App#89) : les paires déjà essayées sans succès (échec ou
+-- « presque »), dans l'ordre des cases. Ni issue, ni date, ni texte du catalogue :
+-- deux briques et le dernier fascicule paru au moment de l'essai. Toutes les
+-- lignes d'un fascicule plus ancien s'oublient en bloc à la parution suivante,
+-- car de vieilles paires peuvent devenir des mots.
+
+CREATE TABLE essai (
+ joueur_id uuid NOT NULL REFERENCES joueur (id) ON DELETE CASCADE,
+ gauche text NOT NULL CHECK (gauche ~ '^brq_[0-9a-hjkmnp-tv-z]{8}$'),
+ droite text NOT NULL CHECK (droite ~ '^brq_[0-9a-hjkmnp-tv-z]{8}$'),
+ fascicule_juge text NOT NULL CHECK (fascicule_juge ~ '^fsc_[0-9a-hjkmnp-tv-z]{8}$'),
+ PRIMARY KEY (joueur_id, gauche, droite)
+);
diff --git a/web/package-lock.json b/web/package-lock.json
index 1050295..b63d4e2 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -4265,9 +4265,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": [
{
@@ -6434,9 +6434,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": {
@@ -7096,9 +7096,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.3",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
+ "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 62d7c99..047254c 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -27,7 +27,7 @@ import { usePresence } from './services/presence'
import { INACTIVITE_MS, type SouvenirSoutien, ajouterTempsDeJeu, carteMasquee, doitSolliciter, estAbonne, fermerSoutien, masquerCarte } from './services/soutien'
import { compteQuitte } from './services/profil'
import { enquetesEnCours, indiceCourant } from './services/indices'
-import { TABLE_MIN, decouvertePossible } from './services/jeu'
+import { TABLE_MIN, cleEssai, decouvertePossible, raisonEssai } from './services/jeu'
import { FournisseurDeblocage, useDeblocageEtat } from './services/deblocage'
import { usePartie } from './services/partie'
import { useReglages } from './services/reglages'
@@ -103,6 +103,13 @@ export function App({ api, catalogue }: Props) {
// Sans silhouette à indicer, l'indice n'afficherait rien : le bouton disparaît.
const indiceDisponible = catalogueJeu ? indiceCourant(catalogueJeu, entrees, etat) !== null : false
const enquetes = useMemo(() => (catalogueJeu ? enquetesEnCours(catalogueJeu, entrees, etat) : []), [catalogueJeu, entrees, etat])
+ // Mémoire des essais (App#89) : les paires notées par le serveur et les mots déjà au codex.
+ const raisonPaire = useMemo(() => {
+ if (!catalogueJeu || !etat) return undefined
+ const decouverts = new Set(etat.decouvertes.map((d) => d.mot_id))
+ const essais = new Set((etat.essais ?? []).map((e) => cleEssai(e.gauche, e.droite)))
+ return (paire: [string, string]) => raisonEssai(paire, catalogueJeu, decouverts, essais)
+ }, [catalogueJeu, etat])
const bloque =
catalogueJeu && etat && partie.table.length === 0
? !decouvertePossible(catalogueJeu, etat.reserve, new Set(etat.decouvertes.map((d) => d.mot_id)))
@@ -276,6 +283,7 @@ export function App({ api, catalogue }: Props) {
onRetirer={partie.retirer}
onComplete={versFusion}
onFermer={fermerReserve}
+ raisonEssai={raisonPaire}
/>
)}
{indices && }
diff --git a/web/src/composants/Reserve.test.tsx b/web/src/composants/Reserve.test.tsx
new file mode 100644
index 0000000..bf614f6
--- /dev/null
+++ b/web/src/composants/Reserve.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen, within } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import { GEO, LOGIE, ex, jeu } from '../test/fixtures'
+import { Reserve } from './Reserve'
+
+describe('Reserve, mémoire des essais', () => {
+ const { briques } = jeu()
+ const reserve = [ex(GEO, 2), ex(LOGIE, 1)]
+
+ it('range à la fin, grisées, les briques déjà essayées avec celle de la table, et les laisse jouables', () => {
+ render(
+ (b === GEO ? 'aucune recette' : null) }}
+ onAjouter={() => {}}
+ onIndisponible={() => {}}
+ />,
+ )
+ const principale = screen.getByRole('list', { name: 'Briques de la réserve' })
+ const essayees = screen.getByRole('list', { name: 'Briques déjà essayées avec celle de la table' })
+ expect(within(principale).getByRole('button', { name: /-logie/ })).toBeInTheDocument()
+ const geo = within(essayees).getByRole('button', { name: /géo-/ })
+ expect(geo).toHaveClass('is-essayee')
+ expect(geo).toHaveTextContent('aucune recette')
+ expect(geo).toHaveAttribute('aria-disabled', 'false')
+ expect(screen.getByText(/Déjà essayées avec/)).toHaveTextContent('· 1')
+ })
+
+ it('ne trie rien sans brique sur la table', () => {
+ render( {}} onIndisponible={() => {}} />)
+ expect(screen.queryByText(/Déjà essayées/)).not.toBeInTheDocument()
+ expect(screen.getAllByRole('list')).toHaveLength(1)
+ })
+})
diff --git a/web/src/composants/Reserve.tsx b/web/src/composants/Reserve.tsx
index 420633c..e183dfb 100644
--- a/web/src/composants/Reserve.tsx
+++ b/web/src/composants/Reserve.tsx
@@ -1,10 +1,11 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { Exemplaires } from '../contrat'
+import { mouvementReduit } from '../services/animation'
import type { BriqueJeu } from '../services/jeu'
import { NATURES, entrelacer, naturesDeCase } from '../services/jeu'
import { correspond } from '../services/recherche'
-import { ContenuBrique } from './Brique'
+import { ContenuBrique, PetiteBrique } from './Brique'
export const TYPE_GLISSE = 'application/x-etymo-brique'
@@ -27,6 +28,11 @@ interface Props {
consommees?: string[]
/** Dernière brique posée ou retirée : sa pastille se contracte (−1) ou gonfle (+1), à chaque nouvelle `cle`. */
touchee?: { brique: string; cle: number; sens: 'plus' | 'moins' } | null
+ /**
+ * Mémoire des essais (App#89) : la brique posée sur la table, et pourquoi chaque brique
+ * a déjà été essayée avec elle (« aucune recette », « au codex »), ou `null`.
+ */
+ memoire?: { posee: string; raison: (brique: string) => string | null } | null
}
/** Largeur relative d'une brique : son libellé (serif, grand) ou sa ligne de type et de sens (mono, petite). */
@@ -43,7 +49,7 @@ function exemplaires(n: number): string {
* par briques disponibles puis récentes. Un toucher ou un clic pose la brique sur la table
* (jeu.html) ; on peut aussi la glisser sur un emplacement.
*/
-export function Reserve({ reserve, briques, table, zone = null, onAjouter, onIndisponible, consommees = [], touchee = null }: Props) {
+export function Reserve({ reserve, briques, table, zone = null, onAjouter, onIndisponible, consommees = [], touchee = null, memoire = null }: Props) {
const [recherche, setRecherche] = useState('')
const [filtre, setFiltre] = useState('tout')
const champ = useRef(null)
@@ -88,10 +94,45 @@ export function Reserve({ reserve, briques, table, zone = null, onAjouter, onInd
return (zone === null || naturesDeCase(zone).includes(b.nature)) && dansLeFiltre(e, filtreActif) && correspond(recherche, b.libelle, b.glose)
})
const taille = (e: Exemplaires) => encombrement(briques.get(e.brique_id) as BriqueJeu)
- const visibles = [
- ...entrelacer(trouvees.filter((e) => e.exemplaires > 0), taille),
- ...entrelacer(trouvees.filter((e) => e.exemplaires <= 0), taille),
+ // Mémoire des essais : les briques déjà essayées avec celle de la table passent en fin de liste, grisées.
+ // Les autres gardent l'ordre habituel : rien ne dit qu'une paire jamais essayée est bonne.
+ const raisons = new Map(
+ memoire
+ ? trouvees.flatMap((e) => {
+ const r = memoire.raison(e.brique_id)
+ return r ? [[e.brique_id, r] as const] : []
+ })
+ : [],
+ )
+ const ordonner = (liste: Exemplaires[]) => [
+ ...entrelacer(liste.filter((e) => e.exemplaires > 0), taille),
+ ...entrelacer(liste.filter((e) => e.exemplaires <= 0), taille),
]
+ const visibles = ordonner(trouvees.filter((e) => !raisons.has(e.brique_id)))
+ const essayees = ordonner(trouvees.filter((e) => raisons.has(e.brique_id)))
+
+ // Les briques glissent vers leur nouvelle place quand la partition change (FLIP, 240 ms).
+ const plateau = useRef(null)
+ const places = useRef(new Map())
+ const partition = [...raisons.keys()].sort().join(' ')
+ const partitionAvant = useRef(partition)
+ useLayoutEffect(() => {
+ const boutons = plateau.current?.querySelectorAll('[data-brique]') ?? []
+ const anime = partitionAvant.current !== partition && !mouvementReduit()
+ partitionAvant.current = partition
+ const suivantes = new Map()
+ for (const el of boutons) {
+ const id = el.dataset.brique ?? ''
+ const apres = el.getBoundingClientRect()
+ suivantes.set(id, apres)
+ const avant = places.current.get(id)
+ if (!anime || !avant || typeof el.animate !== 'function') continue
+ const dx = avant.left - apres.left
+ const dy = avant.top - apres.top
+ if (dx || dy) el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: 240, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' })
+ }
+ places.current = suivantes
+ })
return (
<>
@@ -138,62 +179,86 @@ export function Reserve({ reserve, briques, table, zone = null, onAjouter, onInd
{connues.length === 0 ? (
Votre réserve est vide pour le moment.
- ) : visibles.length === 0 ? (
+ ) : visibles.length + essayees.length === 0 ? (
Aucune brique ne correspond. Essayez un sens, comme « vie » ou « terre ».
) : (
-
- {visibles.map(({ brique_id, exemplaires: possedes }) => {
- const brique = briques.get(brique_id) as BriqueJeu
- const libres = disponibles(brique_id)
- const epuisee = possedes === 0
- const indisponible = epuisee
- ? `Plus aucun exemplaire de ${brique.libelle}. Ouvrez un pli pour en retrouver.`
- : libres <= 0
- ? `Tous vos exemplaires de ${brique.libelle} sont déjà sur la table.`
- : null
- const nom = `${brique.libelle}${brique.glose ? ` (${brique.glose})` : ''}`
- const classes = ['brick', epuisee ? 'is-spent' : libres <= 0 ? 'is-out' : ''].filter(Boolean).join(' ')
- return (
- -
-
)}
-
+
)
diff --git a/web/src/contrat/api.d.ts b/web/src/contrat/api.d.ts
index edd0b89..877b338 100644
--- a/web/src/contrat/api.d.ts
+++ b/web/src/contrat/api.d.ts
@@ -1047,6 +1047,11 @@ export interface components {
/** @description Pseudo déjà pris : un pseudo libre proche. */
proposition?: string | null;
};
+ /** @description Une paire essayée sans succès (échec ou « presque ») : ni issue, ni date. */
+ Essai: {
+ droite: components["schemas"]["IdCatalogue"];
+ gauche: components["schemas"]["IdCatalogue"];
+ };
/**
* @description Projection de l'état confirmé du joueur : des identifiants et des nombres,
* jamais de texte du catalogue (ADR 0027).
@@ -1066,6 +1071,11 @@ export interface components {
encre: number;
/** @description Charges de pli, à l'horloge du serveur (ADR 0020). */
energie: components["schemas"]["EnergieEtat"];
+ /**
+ * @description Paires déjà essayées sans succès depuis la dernière parution (App#89),
+ * dans l'ordre des cases, triées. Vide avant ce champ ou après une parution.
+ */
+ essais?: components["schemas"]["Essai"][];
/**
* @description Fascicules parus que le joueur a débloqués (ADR 0068), triés par
* fascicule. Vide dans une réponse enregistrée avant ce champ, ou sans
diff --git a/web/src/contrat/index.ts b/web/src/contrat/index.ts
index 4b74ac1..038cbc4 100644
--- a/web/src/contrat/index.ts
+++ b/web/src/contrat/index.ts
@@ -46,6 +46,7 @@ export type DemandePli = S['DemandePli']
export type ReponsePli = S['ReponsePli']
export type SabliersEtat = S['SabliersEtat']
export type IndiceObtenu = S['IndiceObtenu']
+export type Essai = S['Essai']
export type DemandeSablier = S['DemandeSablier']
export type ReponseSablier = S['ReponseSablier']
export type DemandeAchatSablier = S['DemandeAchatSablier']
diff --git a/web/src/services/jeu.test.ts b/web/src/services/jeu.test.ts
index 35f6141..f3ea507 100644
--- a/web/src/services/jeu.test.ts
+++ b/web/src/services/jeu.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { GEO, LOGIE, MOT, RECETTE, jeu } from '../test/fixtures'
-import { entrelacer, prevoir, prochainFascicule, rareteMot } from './jeu'
+import { cleEssai, entrelacer, prevoir, prochainFascicule, raisonEssai, rareteMot } from './jeu'
describe('prévisualisation d’une fusion', () => {
it('suit les mêmes règles que le serveur', () => {
@@ -58,3 +58,21 @@ describe('prochainFascicule', () => {
expect(prochainFascicule({ prochaineParution: '2026-10-01T07:00:00Z' }, maintenant)).toBeNull()
})
})
+
+describe('mémoire des essais', () => {
+ const j = jeu()
+ const aucun = new Set()
+
+ it('déduit un mot déjà au codex, sans ligne du serveur', () => {
+ expect(raisonEssai([GEO, LOGIE], j, new Set([MOT]), aucun)).toBe('au codex')
+ // Jamais trouvé : rien ne dit que la paire est bonne.
+ expect(raisonEssai([GEO, LOGIE], j, aucun, aucun)).toBeNull()
+ })
+
+ it('ne montre un échec ou un « presque » que s’il a été essayé, dans cet ordre', () => {
+ expect(raisonEssai([LOGIE, GEO], j, aucun, aucun)).toBeNull()
+ expect(raisonEssai([LOGIE, GEO], j, aucun, new Set([cleEssai(LOGIE, GEO)]))).toBe('presque : inversez')
+ expect(raisonEssai([GEO, GEO], j, aucun, new Set([cleEssai(GEO, GEO)]))).toBe('aucune recette')
+ expect(raisonEssai([GEO, GEO], j, aucun, new Set([cleEssai(LOGIE, GEO)]))).toBeNull()
+ })
+})
diff --git a/web/src/services/jeu.ts b/web/src/services/jeu.ts
index fe070c9..07a14b2 100644
--- a/web/src/services/jeu.ts
+++ b/web/src/services/jeu.ts
@@ -328,3 +328,19 @@ export function entrelacer(elements: T[], taille: (e: T) => number): T[] {
}
return suite
}
+
+/** Clé d'un essai : les deux briques dans l'ordre des cases. */
+export const cleEssai = (gauche: string, droite: string) => `${gauche}|${droite}`
+
+/**
+ * Mémoire des essais (App#89) : pourquoi la paire `[gauche, droite]` est déjà connue du joueur,
+ * ou `null` s'il ne l'a jamais essayée. Un mot au codex se déduit de la règle de fusion ; un échec
+ * ou un « presque » n'est montré que si le serveur l'a noté : rien de ce que le joueur ignore.
+ */
+export function raisonEssai(paire: [string, string], jeu: CatalogueJeu, decouverts: Set, essais: Set): string | null {
+ const prevision = prevoir(paire, jeu, decouverts)
+ if (prevision.type === 'connue') return 'au codex'
+ if (!essais.has(cleEssai(...paire))) return null
+ if (prevision.type === 'presque') return prevision.motif === 'ordre' ? 'presque : inversez' : 'presque'
+ return prevision.type === 'echec' ? 'aucune recette' : null
+}
diff --git a/web/src/styles.css b/web/src/styles.css
index fb2a2e5..5314214 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -397,6 +397,12 @@ button.brick:hover { transform: translate(-2px, -3px) rotate(-1.5deg); box-shado
button.brick:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--pop-shadow); cursor: grabbing; }
/* Rationnement : exemplaires tous posés (désactivé) ou épuisés (pointillé = ce qui manque). */
.brick.is-out { opacity: 0.4; box-shadow: none; cursor: not-allowed; }
+/* Mémoire des essais (App#89) : déjà essayée avec la brique de la table. Sable, libellé en Encre secondaire,
+ sans ombre ; elle reste jouable, puisque se tromper ne coûte rien (ni désactivée, ni épuisée). */
+.brick.is-essayee[data-kind] { border-color: var(--ink-3); background: var(--paper-2); color: var(--ink-2); box-shadow: none; }
+.tray-memoire { display: grid; gap: 4px; }
+.tray-intertitre { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--ink-2); font-family: var(--mono); font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; }
+.tray-intertitre .bchip { font-family: var(--serif); letter-spacing: 0; text-transform: none; }
.brick.is-spent {
border-style: dashed; box-shadow: none; cursor: not-allowed;
background: repeating-linear-gradient(135deg, var(--paper-2) 0 6px, var(--cream) 6px 12px);