From 95281c34b0359959b311afab6d2e2847c4415e28 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:32:32 +0000 Subject: [PATCH 1/3] feat(reserve): remember failed pairs and move them to the end of the reserve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /fusion now notes a failure or a "presque" in a new `essai` table (player, left brick, right brick, last published fascicule; no outcome, no date, no catalogue text) when the player knows both bricks, capped at 3,000 rows, without a command row nor a state version bump. Rows judged under an older fascicule are no longer read and are wiped at the next note: a release forgets every pair at once. `Etat.essais` (serde default) carries them; export and reset include them. With one brick on the table, the reserve sheet moves the bricks already tried with it, in that order, to the end of the list under "Déjà essayées avec …", greyed but still playable, each saying why: "aucune recette", "presque : inversez" or "au codex" (deduced from the fusion rule). The reordering slides over 240 ms, nothing under reduced motion. This departs from ADR 0027 / data model §7 ("fusion proposals are never stored"): an ADR is due in the design repository. Closes #89 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0151stDRaZV3ScbhwvwjrfQ7 --- ...2934da86f159c7a8d3fde4b8eb77f405a6d24.json | 14 ++ ...d762ea1368975f67d4272b08d5880614a3153.json | 15 ++ ...82967e65e57e0f2b61587d6e9489df8cc7b3.json} | 4 +- ...0ea0cde5db1682a44995936bab468a15aa684.json | 18 ++ ...e8484761a7607697f86868bb9442faddf784a.json | 29 +++ contrat/openapi.json | 24 +++ crates/api/src/export.rs | 1 + crates/api/src/progression.rs | 80 +++++++- crates/api/src/routes.rs | 7 +- crates/api/tests/fusion.rs | 139 ++++++++++++- crates/contrat/src/lib.rs | 12 ++ docs/roadmap.md | 1 + migrations/20261023000001_essais.sql | 13 ++ web/src/App.tsx | 10 +- web/src/composants/Reserve.test.tsx | 37 ++++ web/src/composants/Reserve.tsx | 183 ++++++++++++------ web/src/composants/ReserveModale.tsx | 13 +- web/src/contrat/api.d.ts | 10 + web/src/contrat/index.ts | 1 + web/src/services/jeu.test.ts | 20 +- web/src/services/jeu.ts | 16 ++ web/src/styles.css | 6 + 22 files changed, 584 insertions(+), 69 deletions(-) create mode 100644 .sqlx/query-03da70796777c07ea1b77719e222934da86f159c7a8d3fde4b8eb77f405a6d24.json create mode 100644 .sqlx/query-111159fe44528e6218f432dfb69d762ea1368975f67d4272b08d5880614a3153.json rename .sqlx/{query-f01e57df3a38d5466cb93192af44ac9462b9829d9d706291acf249176566c52f.json => query-57fa055950224e18d3a05435769982967e65e57e0f2b61587d6e9489df8cc7b3.json} (69%) create mode 100644 .sqlx/query-9cf046387978435ee9dc64402be0ea0cde5db1682a44995936bab468a15aa684.json create mode 100644 .sqlx/query-d9f5a3f049ee3b351c5f56fa1ebe8484761a7607697f86868bb9442faddf784a.json create mode 100644 migrations/20261023000001_essais.sql create mode 100644 web/src/composants/Reserve.test.tsx 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 b6ff7a0..ec2f075 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/src/App.tsx b/web/src/App.tsx index 1b79b32..34e787f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -26,7 +26,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 { 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' @@ -101,6 +101,13 @@ export function App({ api, catalogue }: Props) { const raisonFusion = bloquee ?? (partie.table.length < TABLE_MIN ? 'Posez deux briques pour fusionner.' : null) // Sans silhouette à indicer, l'indice n'afficherait rien : le bouton disparaît. const indiceDisponible = catalogueJeu ? indiceCourant(catalogueJeu, entrees, etat) !== null : false + // 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))) @@ -274,6 +281,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 ( -
  • - -
  • - ) - })} -
+ ? `Tous vos exemplaires de ${brique.libelle} sont déjà sur la table.` + : null + const nom = `${brique.libelle}${brique.glose ? ` (${brique.glose})` : ''}` + const raison = raisons.get(brique_id) + const classes = ['brick', epuisee ? 'is-spent' : libres <= 0 ? 'is-out' : '', raison ? 'is-essayee' : ''].filter(Boolean).join(' ') + return ( +
  • + +
  • + ) + })} + + + ), + )} + )} ) diff --git a/web/src/composants/ReserveModale.tsx b/web/src/composants/ReserveModale.tsx index 3c87494..e531093 100644 --- a/web/src/composants/ReserveModale.tsx +++ b/web/src/composants/ReserveModale.tsx @@ -68,6 +68,8 @@ interface Props { /** La table vient d'être complétée depuis la modale, qui va se fermer. */ onComplete: () => void onFermer: () => void + /** Mémoire des essais (App#89) : pourquoi une paire, dans l'ordre des cases, est déjà connue, ou `null`. */ + raisonEssai?: (paire: [string, string]) => string | null } /** « 1ʳᵉ », « 2ᵉ » : rang de la brique dans le mot. */ @@ -80,7 +82,7 @@ function ordinal(rang: number): string { * la table en petit : on voit où va chaque brique, et on la retire d'un toucher. * Table complète, elle se ferme d'elle-même : il ne reste qu'à fusionner. */ -export function ReserveModale({ reserve, briques, cases, consommees, exemplaires, rang, onPoser, onRetirer, onComplete, onFermer }: Props) { +export function ReserveModale({ reserve, briques, cases, consommees, exemplaires, rang, onPoser, onRetirer, onComplete, onFermer, raisonEssai }: Props) { const [boite, fermer] = useDialogue(onFermer, fermerEnVol) // Le message reste dans la modale : un message de la table s'afficherait derrière le voile. const [message, setMessage] = useState(null) @@ -162,6 +164,13 @@ export function ReserveModale({ reserve, briques, cases, consommees, exemplaires setAnnonce(`${libelle(id)} retirée de la case ${i + 1}. Choisissez la ${ordinal(i)} brique.`) } + // Une brique sur la table, une case à remplir : la réserve range à part ce qui a déjà été essayé avec elle. + const posee = posees.length === 1 ? posees[0] : undefined + const memoire = + raisonEssai && posee !== undefined && !complete && cible >= 0 + ? { posee, raison: (b: string) => raisonEssai(cible === 0 ? [b, posee] : [posee, b]) } + : null + const compte = `${exemplaires} exemplaire${exemplaires > 1 ? 's' : ''}` const consigne = complete || cible < 0 ? 'La table est prête' : `Choisissez la ${ordinal(cible)} brique` // Toujours une ligne d'aide, pour que la feuille ne change pas de hauteur à la première pose. @@ -239,7 +248,7 @@ export function ReserveModale({ reserve, briques, cases, consommees, exemplaires {message}

    )} - + ) 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 4cc0708..26e9fab 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); From ec9ade50965a3ffa03d309e32e77bc3f766e6072 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:53:09 +0000 Subject: [PATCH 2/3] fix(deps): update rustls-webpki, webpki-roots, ctutils and vite for audits cargo audit flagged RUSTSEC-2026-0381/0382/0383/0385 and npm audit a high advisory on vite 8.3.1 (and moderate ones on vitest): lockfile-only updates to the patched versions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0151stDRaZV3ScbhwvwjrfQ7 (cherry picked from commit a140f55df0d3ce823acc6ff6d9534bce30e72ab8) --- Cargo.lock | 18 +-- web/package-lock.json | 248 +++++++++++++++++++++--------------------- 2 files changed, 133 insertions(+), 133 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d4332a..b306375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -479,9 +479,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] @@ -2168,9 +2168,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", @@ -3235,9 +3235,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", ] @@ -3248,14 +3248,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", ] diff --git a/web/package-lock.json b/web/package-lock.json index c5f9c8a..1050295 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1900,9 +1900,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.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "dev": true, "license": "MIT", "funding": { @@ -2279,9 +2279,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "cpu": [ "arm" ], @@ -2296,9 +2296,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -2313,9 +2313,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -2330,9 +2330,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -2347,9 +2347,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -2364,9 +2364,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -2381,9 +2381,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -2398,9 +2398,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -2415,9 +2415,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -2432,9 +2432,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -2449,9 +2449,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -2466,9 +2466,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -2483,9 +2483,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -2500,9 +2500,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -2517,9 +2517,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.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -3246,22 +3246,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.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.2", + "@vitest/spy": "5.0.5", "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": { @@ -3274,9 +3274,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.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { @@ -3672,9 +3672,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": { @@ -5670,9 +5670,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.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5737,9 +5737,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": [ { @@ -5810,9 +5810,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", @@ -6025,9 +6025,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.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -6045,9 +6045,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" @@ -6270,13 +6270,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.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6286,21 +6286,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.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/rollup": { @@ -6656,9 +6656,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" }, @@ -6890,9 +6890,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.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", "engines": { @@ -7229,16 +7229,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.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -7255,7 +7255,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", @@ -7338,25 +7338,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.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.2", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.5", + "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" @@ -7371,12 +7371,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.0.5", + "@vitest/browser-preview": "5.0.5", "@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.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -7574,9 +7574,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": { From b3daf725ba1c9f0be7039c6c1406d4831e1f040a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:13:12 +0000 Subject: [PATCH 3/3] fix(deps): update undici, fast-uri and serialize-javascript for npm audit New advisories (GHSA-4qv8-2p9j-2685 and others) turn npm audit red on develop too; lockfile-only update to the patched versions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0151stDRaZV3ScbhwvwjrfQ7 --- web/package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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": {