diff --git a/contrat/openapi.json b/contrat/openapi.json index bbd353c..2cd3d8b 100644 --- a/contrat/openapi.json +++ b/contrat/openapi.json @@ -2293,6 +2293,41 @@ "premiere_carte" ] }, + "Collection": { + "type": "object", + "description": "Quête de collection d'un fascicule (App#90) : tous ses préfixes, tous ses\nsuffixes, tous ses mots d'une rareté. Le catalogue en publie les membres,\nhors légendaires (ADR 0043) ; sa récompense se récupère comme un palier.", + "required": [ + "id", + "critere", + "membres" + ], + "properties": { + "critere": { + "$ref": "#/components/schemas/CritereCollection" + }, + "encre": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "id": { + "$ref": "#/components/schemas/IdCatalogue", + "description": "Identifiant de palier : la récupération passe par `palier_recupere`." + }, + "membres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IdCatalogue" + }, + "description": "Briques (préfixes, suffixes) ou mots (raretés) à réunir." + }, + "sabliers": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "CompositionCarte": { "type": "object", "required": [ @@ -2391,6 +2426,17 @@ "non_retenue" ] }, + "CritereCollection": { + "type": "string", + "description": "Ce qu'une quête de collection rassemble (App#90) : la liste fermée.", + "enum": [ + "prefixes", + "suffixes", + "mots_communs", + "mots_peu_communs", + "mots_rares" + ] + }, "Decouverte": { "type": "object", "required": [ @@ -3338,6 +3384,13 @@ }, "description": "Prix de chaque indice, en gouttes : silhouette, sens littéral, définition." }, + "progression_collections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgressionCollection" + }, + "description": "Quêtes de collection des fascicules débloqués (App#90) ; vide avant ce\nchamp ou sans catalogue. Leur récupération passe par `paliers_recuperes`." + }, "progression_quetes": { "type": "array", "items": { @@ -3824,6 +3877,13 @@ "$ref": "#/components/schemas/CarteManifeste" } }, + "collections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Collection" + }, + "description": "Quêtes de collection du fascicule (App#90), membres hors légendaires." + }, "compteurs": {}, "exclusions": { "oneOf": [ @@ -4131,6 +4191,35 @@ "suffixe" ] }, + "ProgressionCollection": { + "type": "object", + "description": "Progression d'une quête de collection (App#90), d'un fascicule débloqué\nseulement : un fascicule verrouillé n'expose aucun total (ADR 0069).", + "required": [ + "palier_id", + "fascicule_id", + "progression", + "total" + ], + "properties": { + "fascicule_id": { + "$ref": "#/components/schemas/IdCatalogue" + }, + "palier_id": { + "$ref": "#/components/schemas/IdCatalogue" + }, + "progression": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "additionalProperties": false + }, "ProgressionQuete": { "type": "object", "required": [ diff --git a/crates/api/src/progression.rs b/crates/api/src/progression.rs index 9c2b2f1..e9b9e95 100644 --- a/crates/api/src/progression.rs +++ b/crates/api/src/progression.rs @@ -8,7 +8,8 @@ use chrono::{DateTime, TimeDelta, Utc}; use etymo_catalogue::Jeu; use etymo_contrat::{ CadeauEtat, CompteEtat, CompteurPli, Decouverte, EnergieEtat, Essai, Etat, Exemplaires, - FasciculeDebloque, IndiceObtenu, IssueFusion, OuverturePli, ProgressionQuete, SabliersEtat, + FasciculeDebloque, IndiceObtenu, IssueFusion, OuverturePli, ProgressionCollection, + ProgressionQuete, SabliersEtat, }; use etymo_domaine::IdCatalogue; use etymo_domaine::carte_de_lecteur; @@ -145,6 +146,7 @@ pub async fn etat( fascicules_debloques: lire_fascicules_debloques(&mut *tx, joueur).await?, compte: compte_etat(&mut *tx, joueur).await?, essais: lire_essais(&mut *tx, joueur, jeu).await?, + progression_collections: progression_collections(&mut *tx, joueur, jeu).await?, }) } @@ -220,6 +222,37 @@ pub async fn noter_essai( Ok(true) } +/// Quêtes de collection des fascicules débloqués (App#90), à la valeur du codex. +async fn progression_collections( + tx: &mut PgConnection, + joueur: Uuid, + jeu: Option<&Jeu>, +) -> Result, ErreurApi> { + let Some(jeu) = jeu.filter(|j| !j.collections.is_empty()) else { + return Ok(Vec::new()); + }; + let debloques = fascicules_debloques(&mut *tx, joueur).await?; + let mots = mots_decouverts(&mut *tx, joueur).await?; + let briques = briques_connues(&mut *tx, joueur).await?; + let codex = Codex { + mots: &mots, + briques: &briques, + }; + Ok(jeu + .collections + .iter() + .filter(|(f, _)| debloques.contains(f)) + .flat_map(|(f, quetes)| { + quetes.iter().map(|q| ProgressionCollection { + palier_id: q.id.clone(), + fascicule_id: f.clone(), + progression: q.progression(&codex), + total: q.total(), + }) + }) + .collect()) +} + /// Compte du joueur, absent pour une partie invitée (ADR 0070). pub(crate) async fn compte_etat( tx: &mut PgConnection, @@ -1306,30 +1339,47 @@ pub async fn recuperer_palier( commande: Uuid, palier_id: &IdCatalogue, ) -> Result<(), ErreurApi> { - let (quete, palier) = jeu - .quetes - .iter() - .find_map(|q| { - q.paliers - .iter() - .find(|p| &p.id == palier_id) - .map(|p| (q, p)) - }) - .ok_or_else(|| ErreurApi::RequeteInvalide("palier : inconnu".into()))?; - let progression = progression_quetes(&mut *tx, joueur, jeu) - .await? - .into_iter() - .find(|p| p.quete_id == quete.id) - .map_or(0, |p| p.progression); - if !quete::atteint(palier, progression) { - return Err(ErreurApi::Interdit("palier pas encore atteint")); - } + let (encre, sabliers) = if let Some((quete, palier)) = jeu.quetes.iter().find_map(|q| { + q.paliers + .iter() + .find(|p| &p.id == palier_id) + .map(|p| (q, p)) + }) { + let progression = progression_quetes(&mut *tx, joueur, jeu) + .await? + .into_iter() + .find(|p| p.quete_id == quete.id) + .map_or(0, |p| p.progression); + if !quete::atteint(palier, progression) { + return Err(ErreurApi::Interdit("palier pas encore atteint")); + } + (palier.encre, palier.sabliers) + } else { + // Quête de collection (App#90) : un fascicule verrouillé ne se récupère pas. + let collection = jeu + .collections + .iter() + .flat_map(|(_, q)| q) + .find(|q| &q.id == palier_id) + .ok_or_else(|| ErreurApi::RequeteInvalide("palier : inconnu".into()))?; + let progression = progression_collections(&mut *tx, joueur, Some(jeu)) + .await? + .into_iter() + .find(|p| &p.palier_id == palier_id); + match progression { + None => return Err(ErreurApi::Interdit("fascicule verrouillé")), + Some(p) if p.progression < p.total => { + return Err(ErreurApi::Interdit("palier pas encore atteint")); + } + Some(_) => (collection.encre, collection.sabliers), + } + }; let maintenant = Utc::now(); let nouveau = sqlx::query_scalar!( "INSERT INTO palier_recupere (joueur_id, palier_id, recupere_le) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING palier_id", joueur, - palier.id.as_str(), + palier_id.as_str(), maintenant, ) .fetch_optional(&mut *tx) @@ -1337,7 +1387,7 @@ pub async fn recuperer_palier( if nouveau.is_none() { return Err(ErreurApi::Conflit("palier déjà récupéré")); } - let gouttes = i32::try_from(palier.encre).map_err(|e| ErreurApi::Interne(e.to_string()))?; + let gouttes = i32::try_from(encre).map_err(|e| ErreurApi::Interne(e.to_string()))?; crediter_encre( &mut *tx, joueur, @@ -1347,7 +1397,7 @@ pub async fn recuperer_palier( maintenant, ) .await?; - let sabliers = i32::try_from(palier.sabliers).map_err(|e| ErreurApi::Interne(e.to_string()))?; + let sabliers = i32::try_from(sabliers).map_err(|e| ErreurApi::Interne(e.to_string()))?; crediter_sabliers( &mut *tx, joueur, diff --git a/crates/api/tests/deblocage.rs b/crates/api/tests/deblocage.rs index 3fd0121..f4e0e47 100644 --- a/crates/api/tests/deblocage.rs +++ b/crates/api/tests/deblocage.rs @@ -146,3 +146,21 @@ async fn un_seul_jalon_ne_debloque_rien(base: PgPool) { assert_eq!(statut, StatusCode::OK, "{corps}"); assert_eq!(debloques(&corps["etat"]), vec![PREMIER]); } + +#[sqlx::test(migrations = "../../migrations")] +async fn les_collections_d_un_fascicule_verrouille_ne_se_montrent_pas(base: PgPool) { + let banc = banc(base, Limiteurs::default()).await; + publier(&banc, true).await; + let jeton = session(&banc.routeur).await; + let (_, _, etat) = appeler(&banc.routeur, get_etat(&jeton)).await; + assert_eq!(debloques(&etat), vec![PREMIER]); + // Aucun total d'un fascicule verrouillé (ADR 0069, App#90). + let fascicules: Vec<&str> = etat["progression_collections"] + .as_array() + .unwrap() + .iter() + .map(|p| p["fascicule_id"].as_str().unwrap()) + .collect(); + assert!(!fascicules.is_empty()); + assert!(fascicules.iter().all(|f| *f == PREMIER), "{fascicules:?}"); +} diff --git a/crates/api/tests/quetes.rs b/crates/api/tests/quetes.rs index 60955dc..44e5fba 100644 --- a/crates/api/tests/quetes.rs +++ b/crates/api/tests/quetes.rs @@ -7,7 +7,8 @@ mod commun; use axum::http::StatusCode; use etymo_api::Limiteurs; use etymo_catalogue::essai::{ - GEO, LOGIE, PALIER_ELISION, PALIER_LANGUE_1, PALIER_LANGUE_3, PALIER_RICOCHETS, QUETE_LANGUE, + COLLECTION_MOTS, COLLECTION_PREFIXES, GEO, LOGIE, PALIER_ELISION, PALIER_LANGUE_1, + PALIER_LANGUE_3, PALIER_RICOCHETS, QUETE_LANGUE, }; use serde_json::{Value, json}; use sqlx::PgPool; @@ -100,3 +101,50 @@ async fn un_palier_pas_encore_atteint_ou_inconnu_est_refuse(base: PgPool) { let (statut, _) = recuperer(&banc, &jeton, etat, PALIER_ELISION).await; assert_eq!(statut, StatusCode::OK); } + +// ------------------------------------------------------------ quêtes de collection (App#90) + +fn collection(etat: &Value, palier: &str) -> (u64, u64) { + etat["progression_collections"] + .as_array() + .unwrap() + .iter() + .find(|p| p["palier_id"] == palier) + .map_or((u64::MAX, u64::MAX), |p| { + ( + p["progression"].as_u64().unwrap(), + p["total"].as_u64().unwrap(), + ) + }) +} + +#[sqlx::test(migrations = "../../migrations")] +async fn une_collection_se_recupere_une_fois_complete(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; + // Graines : géo- et -logie ; bio- manque aux préfixes, le mot n'est pas trouvé. + assert_eq!(collection(&etat, COLLECTION_PREFIXES), (1, 2)); + assert_eq!(collection(&etat, COLLECTION_MOTS), (0, 1)); + let (statut, _) = recuperer(&banc, &jeton, &etat, COLLECTION_MOTS).await; + assert_eq!(statut, StatusCode::FORBIDDEN); + + let demande = commande(&etat, json!({ "briques": [GEO, LOGIE] })); + let (_, _, reponse) = appeler(&banc.routeur, post_fusion(&jeton, &demande)).await; + let etat = reponse["etat"].clone(); + assert_eq!(collection(&etat, COLLECTION_MOTS), (1, 1)); + // Le mot offre sa brique-mot bio- : les deux préfixes sont réunis. + assert_eq!(collection(&etat, COLLECTION_PREFIXES), (2, 2)); + + let encre = somme_mouvements(&base, "encre").await; + let (statut, reponse) = recuperer(&banc, &jeton, &etat, COLLECTION_PREFIXES).await; + assert_eq!(statut, StatusCode::OK, "{reponse}"); + assert_eq!(somme_mouvements(&base, "encre").await, encre + 20); + assert_eq!(somme_mouvements(&base, "sablier").await, 1); + assert_eq!( + reponse["etat"]["paliers_recuperes"], + json!([COLLECTION_PREFIXES]) + ); + let (statut, _) = recuperer(&banc, &jeton, &reponse["etat"], COLLECTION_PREFIXES).await; + assert_eq!(statut, StatusCode::CONFLICT); +} diff --git a/crates/catalogue/src/essai.rs b/crates/catalogue/src/essai.rs index 3d2cd05..92a0d5a 100644 --- a/crates/catalogue/src/essai.rs +++ b/crates/catalogue/src/essai.rs @@ -41,6 +41,9 @@ pub const PALIER_LANGUE_1: &str = "pal_0000000a"; pub const PALIER_LANGUE_3: &str = "pal_0000000b"; pub const PALIER_ELISION: &str = "pal_0000000c"; pub const PALIER_RICOCHETS: &str = "pal_0000000d"; +/// Quêtes de collection du fascicule d'essai (App#90) : ses deux préfixes, son mot commun. +pub const COLLECTION_PREFIXES: &str = "pal_000c0001"; +pub const COLLECTION_MOTS: &str = "pal_000c0002"; /// Écrit un fascicule minimal (une carte, une recette, des exclusions, une /// jaquette, des graines) et renvoie le chemin de son manifeste. @@ -138,6 +141,10 @@ pub fn fascicule_avec_jalons(racine: &Path, numero: u32, parution: &str, jalons: } }, "jalons": jalons, + "collections": [ + { "id": COLLECTION_PREFIXES, "critere": "prefixes", "encre": 20, "sabliers": 1, "membres": [BIO, GEO] }, + { "id": COLLECTION_MOTS, "critere": "mots_communs", "encre": 20, "sabliers": 0, "membres": [MOT] } + ], "graines": [ { "brique_id": GEO, "exemplaires": 2 }, { "brique_id": LOGIE, "exemplaires": 1 } diff --git a/crates/catalogue/src/format.rs b/crates/catalogue/src/format.rs index 07bf671..87c68a5 100644 --- a/crates/catalogue/src/format.rs +++ b/crates/catalogue/src/format.rs @@ -10,7 +10,7 @@ use etymo_domaine::IdCatalogue; use etymo_domaine::cadeau::CadeauDeBienvenue; use etymo_domaine::jalon::Jalon; use etymo_domaine::pli::{Equilibrage, NaturePli, Rarete}; -use etymo_domaine::quete::QueteLongCours; +use etymo_domaine::quete::{Collection, QueteLongCours}; use serde::{Deserialize, Serialize}; use crate::Chemin; @@ -88,6 +88,9 @@ pub struct Manifeste { /// De quoi juger un ricochet sans lire un texte (ADR 0044). #[serde(default)] pub ricochets: RicochetsManifeste, + /// Quêtes de collection du fascicule (App#90), membres hors légendaires. + #[serde(default)] + pub collections: Vec, } /// Données de jugement des ricochets d'un fascicule : des identifiants, jamais de texte. diff --git a/crates/catalogue/src/jeu.rs b/crates/catalogue/src/jeu.rs index eeadea8..de48df5 100644 --- a/crates/catalogue/src/jeu.rs +++ b/crates/catalogue/src/jeu.rs @@ -9,7 +9,7 @@ use etymo_domaine::deblocage::{self, Maillon}; use etymo_domaine::fusion::{ExclusionJeu, RecetteJeu}; use etymo_domaine::jalon::Jalon; use etymo_domaine::pli::{self, BriquePli}; -use etymo_domaine::quete::{CarteQuete, Famille, Mesures, QueteLongCours}; +use etymo_domaine::quete::{CarteQuete, Collection, Famille, Mesures, QueteLongCours}; use crate::carte::Nature; use crate::format::{BriqueRicochet, Difficulte, MotRicochet, Role}; @@ -43,6 +43,8 @@ pub struct Jeu { pub mesures: Mesures, /// Cadeau de bienvenue déclaré par l'index (ADR 0046), réduit à ses briques publiées. pub cadeau: Option, + /// Quêtes de collection de chaque fascicule paru (App#90), par identifiant de fascicule. + pub collections: Vec<(IdCatalogue, Vec)>, } /// Données de jugement des ricochets, par carte. @@ -129,6 +131,14 @@ impl Jeu { } impl Jeu { + /// Ce qui se lit sur l'ensemble des fascicules parus : la chaîne de + /// déblocage, les quêtes de collection, les ricochets sans légendaire. + fn completer(&mut self, fascicules: &[FasciculeCharge]) { + self.chaine = chaine(fascicules); + self.collections = collections(fascicules); + self.ecarter_les_legendaires_des_ricochets(); + } + /// Un ricochet ne vise ni une légendaire ni un mot qui en exige une : même /// règle que les recettes secrètes (ADR 0015), le contenu l'applique déjà. fn ecarter_les_legendaires_des_ricochets(&mut self) { @@ -335,12 +345,21 @@ impl Jeu { .mots .extend(ricochets.mots.iter().map(|m| (m.id.clone(), m.clone()))); } - jeu.chaine = chaine(fascicules); - jeu.ecarter_les_legendaires_des_ricochets(); + jeu.completer(fascicules); jeu } } +/// Quêtes de collection de chaque fascicule paru qui en déclare (App#90). +fn collections(fascicules: &[FasciculeCharge]) -> Vec<(IdCatalogue, Vec)> { + fascicules + .iter() + .map(|f| &f.manifeste) + .filter(|m| !m.collections.is_empty()) + .map(|m| (m.id.clone(), m.collections.clone())) + .collect() +} + /// Fascicules parus par numéro, chacun avec le jalon qui ouvre le suivant (ADR 0068). fn chaine(fascicules: &[FasciculeCharge]) -> Vec { let mut parus: Vec<_> = fascicules.iter().map(|f| &f.manifeste).collect(); diff --git a/crates/contrat/src/lib.rs b/crates/contrat/src/lib.rs index 4bec68b..69b2181 100644 --- a/crates/contrat/src/lib.rs +++ b/crates/contrat/src/lib.rs @@ -124,6 +124,10 @@ pub struct Etat { /// dans l'ordre des cases, triées. Vide avant ce champ ou après une parution. #[serde(default)] pub essais: Vec, + /// Quêtes de collection des fascicules débloqués (App#90) ; vide avant ce + /// champ ou sans catalogue. Leur récupération passe par `paliers_recuperes`. + #[serde(default)] + pub progression_collections: Vec, } /// Une paire essayée sans succès (échec ou « presque ») : ni issue, ni date. @@ -583,6 +587,17 @@ pub struct ReponseRicochet { pub etat: Etat, } +/// Progression d'une quête de collection (App#90), d'un fascicule débloqué +/// seulement : un fascicule verrouillé n'expose aucun total (ADR 0069). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct ProgressionCollection { + pub palier_id: IdCatalogue, + pub fascicule_id: IdCatalogue, + pub progression: u32, + pub total: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] pub struct ProgressionQuete { diff --git a/crates/domaine/src/quete.rs b/crates/domaine/src/quete.rs index 7789c74..6086414 100644 --- a/crates/domaine/src/quete.rs +++ b/crates/domaine/src/quete.rs @@ -146,6 +146,51 @@ impl Mesures { } } +/// Ce qu'une quête de collection rassemble (App#90) : la liste fermée. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CritereCollection { + Prefixes, + Suffixes, + MotsCommuns, + MotsPeuCommuns, + MotsRares, +} + +/// Quête de collection d'un fascicule (App#90) : tous ses préfixes, tous ses +/// suffixes, tous ses mots d'une rareté. Le catalogue en publie les membres, +/// hors légendaires (ADR 0043) ; sa récompense se récupère comme un palier. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Collection { + /// Identifiant de palier : la récupération passe par `palier_recupere`. + pub id: IdCatalogue, + pub critere: CritereCollection, + #[serde(default)] + pub encre: u32, + #[serde(default)] + pub sabliers: u32, + /// Briques (préfixes, suffixes) ou mots (raretés) à réunir. + pub membres: Vec, +} + +impl Collection { + /// Membres déjà au codex : un mot découvert, une brique connue. + #[must_use] + pub fn progression(&self, codex: &Codex<'_, S>) -> u32 { + let n = self + .membres + .iter() + .filter(|m| codex.mots.contains(*m) || codex.briques.contains(*m)) + .count(); + u32::try_from(n).unwrap_or(u32::MAX) + } + + #[must_use] + pub fn total(&self) -> u32 { + u32::try_from(self.membres.len()).unwrap_or(u32::MAX) + } +} + /// Palier atteint à cette progression. #[must_use] pub fn atteint(palier: &Palier, progression: u32) -> bool { @@ -289,4 +334,23 @@ mod tests { assert!(!atteint(&palier, 2)); assert!(atteint(&palier, 3)); } + + #[test] + fn une_collection_compte_ses_membres_au_codex() { + let collection = Collection { + id: id("pal_00000c01"), + critere: CritereCollection::Prefixes, + encre: 20, + sabliers: 1, + membres: vec![id("brq_00000001"), id("brq_00000002"), id("unt_00000003")], + }; + let briques = HashSet::from([id("brq_00000001"), id("brq_00000009")]); + let mots = HashSet::from([id("unt_00000003")]); + let codex = Codex { + mots: &mots, + briques: &briques, + }; + assert_eq!(collection.progression(&codex), 2); + assert_eq!(collection.total(), 3); + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index bee7a58..ae9244f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -162,6 +162,7 @@ Reportés : file d’attente des fusions hors connexion (supprimée, ADR 0041), | Tâche | État | |---|---| | ADR 0042 : page Quêtes, récompenses de jalon à récupérer d’un geste ; ADR 0038 marqué partiellement remplacé (Design) | proposé | +| Quêtes de collection par fascicule (App#90) : tous les préfixes, tous les suffixes, tous les mots communs, peu communs ou rares, déclarés par le fascicule (Content, membres publiés hors légendaires) ; `Etat.progression_collections` des seuls fascicules débloqués ; récupération par `POST /palier` et `palier_recupere` (`403` verrouillé ou incomplet, `409` déjà récupérée) ; bloc « Collection » sous les jalons de l’onglet Fascicules, compté dans les pastilles | fait en local ; ADR à écrire (Design) ; reste : briques reprises dans le filtre du codex, 3ᵉ jalon hors légendaires | | Base : `jalon_atteint.recupere_le` (les jalons déjà accordés sont marqués récupérés), commande `jalon` | fait | | API : la découverte et `GET /etat` marquent les jalons atteints sans rien accorder ; `POST /jalon` récupère la récompense (pli de jalon déterministe, encre si réserve pleine, sabliers), idempotente, `403` pas encore atteint, `409` déjà récupérée, `400` inconnu ; `jalons_recuperes` dans l’état | fait | | PWA : destination Quêtes (`#/quetes`), onglets Journalières (bientôt disponibles) et Fascicules (`#/quetes/fascicules?fascicule=0`) ; sélecteur de fascicule partagé avec le codex, compteur Corail des récompenses qui attendent (navigation, onglet, fascicule) ; jalons retirés de la vue Objectifs du codex, qui renvoie aux quêtes ; « Récupérer la récompense » sous la découverte qui atteint un jalon | fait | diff --git a/web/src/composants/Quetes.tsx b/web/src/composants/Quetes.tsx index 26f3d55..cdcc9c8 100644 --- a/web/src/composants/Quetes.tsx +++ b/web/src/composants/Quetes.tsx @@ -6,7 +6,7 @@ import { type CatalogueJeu, libelleFascicule } from '../services/jeu' import { briquesDuPli, chances, LIBELLES_RARETES, pourcentage } from '../services/plis' import { sensLitteral } from '../services/ricochets' import { conditionDeblocage, jalonDeblocant, useDeblocage } from '../services/deblocage' -import { type PalierSuivi, type QueteJalon, type QueteLongue, SORTES, quetesAuLongCours, quetesDeFascicule } from '../services/quetes' +import { CRITERES, type PalierSuivi, type QueteJalon, type QueteLongue, SORTES, quetesAuLongCours, quetesDeCollection, quetesDeFascicule } from '../services/quetes' import type { OngletQuetes, Route } from '../services/route' import { PetiteBrique } from './Brique' import { SelecteurFascicules, TitreFascicule } from './Fascicules' @@ -133,13 +133,15 @@ function texteRecompensePalier({ palier }: PalierSuivi): string { interface PalierProps { suivi: PalierSuivi + /** À la place du seuil : l'intitulé d'une quête de collection (« Tous les préfixes »). */ + titre?: string inactif: string | null recuperer: (palier: string) => Promise onAller?: (d: Destination) => void } /** Un palier : seuil, progression, récompense annoncée d'avance et, une fois atteint, le bouton pour la récupérer. */ -function CartePalier({ suivi, inactif, recuperer, onAller }: PalierProps) { +function CartePalier({ suivi, titre, inactif, recuperer, onAller }: PalierProps) { const [enCours, setEnCours] = useState(false) const [message, setMessage] = useState<{ texte: string; ton: 'win' | 'fail' } | null>(null) const { palier, etat, avancee } = suivi @@ -152,7 +154,7 @@ function CartePalier({ suivi, inactif, recuperer, onAller }: PalierProps) { return (
  • - {palier.seuil} + {titre ? {titre} : {palier.seuil}} {etat === 'recupere' ? '✓ Récupérée' : etat === 'a_recuperer' ? '✓ Atteint' : `${avancee} / ${palier.seuil}`}
    @@ -535,12 +537,14 @@ export function Quetes({ jeu, etat, entrees, vue, onChangerVue, urlMedia, recupe const deblocage = useDeblocage() const quetes = quetesDeFascicule(jeu, etat) const aRecuperer = quetes.filter((q) => q.etat === 'a_recuperer') + const collections = quetesDeCollection(jeu, etat) + const collectionsARecuperer = collections.filter((q) => q.suivi.etat === 'a_recuperer') const longues = quetesAuLongCours(jeu, etat) const paliersARecuperer = longues.flatMap((q) => q.paliers).filter((p) => p.etat === 'a_recuperer').length const attente: Record = { journalieres: (etat?.quetes_du_jour ?? []).filter((q) => q.recompense && q.faite && !q.recuperee).length + (etat?.carte_de_lecteur?.a_recuperer ?? 0) + (etat?.cadeau_de_bienvenue && !etat.cadeau_de_bienvenue.recupere ? 1 : 0), ponctuelles: (etat?.quetes_initiales ?? []).filter((q) => q.faite && !q.recuperee).length, - fascicules: aRecuperer.length, + fascicules: aRecuperer.length + collectionsARecuperer.length, 'au-long-cours': paliersARecuperer, } // Sans onglet choisi, le premier onglet où une récompense attend passe devant. @@ -656,10 +660,11 @@ export function Quetes({ jeu, etat, entrees, vue, onChangerVue, urlMedia, recupe actif={fascicule} onChoisir={(f) => onChangerVue({ fascicule: f === 'tous' ? null : f }, { remplacer: true })} urlMedia={urlMedia} - aRecuperer={(f) => aRecuperer.filter((q) => q.fascicule.numero === f.numero).length} + aRecuperer={(f) => aRecuperer.filter((q) => q.fascicule.numero === f.numero).length + collectionsARecuperer.filter((q) => q.fascicule.numero === f.numero).length} /> {affichees.map((f) => { const siennes = quetes.filter((q) => q.fascicule.numero === f.numero) + const sesCollections = collections.filter((q) => q.fascicule.numero === f.numero) const verrouille = deblocage ? !deblocage.debloques.has(f.manifeste.id) : false return (
    @@ -680,6 +685,16 @@ export function Quetes({ jeu, etat, entrees, vue, onChangerVue, urlMedia, recupe ))} )} + {!verrouille && sesCollections.length > 0 && ( + <> +

    Collection

    +
      + {sesCollections.map((q) => ( + + ))} +
    + + )}
    ) })} diff --git a/web/src/contrat/api.d.ts b/web/src/contrat/api.d.ts index 877b338..aab6f12 100644 --- a/web/src/contrat/api.d.ts +++ b/web/src/contrat/api.d.ts @@ -644,6 +644,22 @@ export interface components { * @enum {string} */ CodeQueteInitiale: "trois_mots" | "transformation" | "premiers_ricochets" | "premiere_carte"; + /** + * @description Quête de collection d'un fascicule (App#90) : tous ses préfixes, tous ses + * suffixes, tous ses mots d'une rareté. Le catalogue en publie les membres, + * hors légendaires (ADR 0043) ; sa récompense se récupère comme un palier. + */ + Collection: { + critere: components["schemas"]["CritereCollection"]; + /** Format: int32 */ + encre?: number; + /** @description Identifiant de palier : la récupération passe par `palier_recupere`. */ + id: components["schemas"]["IdCatalogue"]; + /** @description Briques (préfixes, suffixes) ou mots (raretés) à réunir. */ + membres: components["schemas"]["IdCatalogue"][]; + /** Format: int32 */ + sabliers?: number; + }; CompositionCarte: { fait?: components["schemas"]["Fait"] | null; id: components["schemas"]["IdCatalogue"]; @@ -684,6 +700,11 @@ export interface components { }; /** @enum {string} */ Confiance: "etablie" | "probable" | "discutee" | "non_retenue"; + /** + * @description Ce qu'une quête de collection rassemble (App#90) : la liste fermée. + * @enum {string} + */ + CritereCollection: "prefixes" | "suffixes" | "mots_communs" | "mots_peu_communs" | "mots_rares"; Decouverte: { /** Format: date-time */ decouverte_le: string; @@ -1103,6 +1124,11 @@ export interface components { paliers_recuperes?: components["schemas"]["IdCatalogue"][]; /** @description Prix de chaque indice, en gouttes : silhouette, sens littéral, définition. */ prix_indices?: number[]; + /** + * @description Quêtes de collection des fascicules débloqués (App#90) ; vide avant ce + * champ ou sans catalogue. Leur récupération passe par `paliers_recuperes`. + */ + progression_collections?: components["schemas"]["ProgressionCollection"][]; /** * @description Progression de chaque quête au long cours, calculée par le serveur sur * le codex (ADR 0045). Un palier est atteint quand elle franchit son seuil. @@ -1259,6 +1285,8 @@ export interface components { }; Manifeste: { cartes?: components["schemas"]["CarteManifeste"][]; + /** @description Quêtes de collection du fascicule (App#90), membres hors légendaires. */ + collections?: components["schemas"]["Collection"][]; compteurs?: unknown; exclusions?: components["schemas"]["Chemin"] | null; /** Format: int32 */ @@ -1359,6 +1387,18 @@ export interface components { * @enum {string} */ PositionBrique: "prefixe" | "suffixe"; + /** + * @description Progression d'une quête de collection (App#90), d'un fascicule débloqué + * seulement : un fascicule verrouillé n'expose aucun total (ADR 0069). + */ + ProgressionCollection: { + fascicule_id: components["schemas"]["IdCatalogue"]; + palier_id: components["schemas"]["IdCatalogue"]; + /** Format: int32 */ + progression: number; + /** Format: int32 */ + total: number; + }; ProgressionQuete: { /** Format: int32 */ progression: number; diff --git a/web/src/contrat/index.ts b/web/src/contrat/index.ts index 038cbc4..4fd16ed 100644 --- a/web/src/contrat/index.ts +++ b/web/src/contrat/index.ts @@ -31,6 +31,9 @@ export type Erreur = S['Erreur'] export type Calendrier = S['Calendrier'] export type EntreeFascicule = S['EntreeFascicule'] export type Manifeste = S['Manifeste'] +export type Collection = S['Collection'] +export type CritereCollection = S['CritereCollection'] +export type ProgressionCollection = S['ProgressionCollection'] export type Recette = S['Recette'] export type Exclusions = S['Exclusions'] export type Carte = S['Carte'] diff --git a/web/src/quetes.css b/web/src/quetes.css index 53d2b89..56b2444 100644 --- a/web/src/quetes.css +++ b/web/src/quetes.css @@ -38,6 +38,9 @@ .quete-tete { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 12px; } .quetes-liste .quete-tete { flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 6px; } .quete-seuil { font-family: var(--serif); font-size: 20px; font-weight: 600; } +/* Quête de collection (App#90) : son intitulé à la place du seuil. */ +.quete-titre-collection { font-family: var(--serif); font-size: 17px; font-weight: 600; line-height: 1.25; } +.quetes-sous-titre { margin: 18px 0 8px; color: var(--ink-2); font-family: var(--mono); font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } .quete-avancee { font-family: var(--mono); font-size: 12px; font-weight: 700; } .quete-pied { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-top: auto; } .quete-pied .btn { margin-left: auto; } diff --git a/web/src/services/quetes.test.ts b/web/src/services/quetes.test.ts index 8a30c68..833a714 100644 --- a/web/src/services/quetes.test.ts +++ b/web/src/services/quetes.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { GEO, MOT, RECETTE, etat, jeu, manifeste } from '../test/fixtures' -import { quetesAuLongCours, quetesDeFascicule, recompensesARecuperer } from './quetes' +import { quetesAuLongCours, quetesDeCollection, quetesDeFascicule, recompensesARecuperer } from './quetes' const JALON = 'jal_0000000a' const SUIVANT = 'jal_0000000b' @@ -98,3 +98,44 @@ describe('quêtes d’un fascicule verrouillé (ADR 0068)', () => { expect(quetesDeFascicule(avecJalons(), etat({ decouvertes: decouvert }))).toHaveLength(2) }) }) + +describe('quêtes de collection (App#90)', () => { + const PREFIXES = 'pal_000c0001' + const RARES = 'pal_000c0002' + function avecCollections() { + const j = avecJalons() + const m = { + ...j.fascicules[0]!.manifeste, + collections: [ + { id: RARES, critere: 'mots_rares' as const, encre: 40, sabliers: 1, membres: ['unt_1', 'unt_2', 'unt_3'] }, + { id: PREFIXES, critere: 'prefixes' as const, encre: 20, sabliers: 1, membres: [GEO, 'brq_2', 'brq_3'] }, + ], + } + return { ...j, fascicules: [{ ...j.fascicules[0]!, manifeste: m }] } + } + const progression = (prefixes: number, rares: number) => [ + { palier_id: PREFIXES, fascicule_id: manifeste.id, progression: prefixes, total: 3 }, + { palier_id: RARES, fascicule_id: manifeste.id, progression: rares, total: 3 }, + ] + + it('lit la progression du serveur, dans l’ordre des critères', () => { + const quetes = quetesDeCollection(avecCollections(), etat({ progression_collections: progression(1, 0) })) + expect(quetes.map((q) => [q.collection.critere, q.suivi.avancee, q.suivi.palier.seuil, q.suivi.etat])).toEqual([ + ['prefixes', 1, 3, 'en_cours'], + ['mots_rares', 0, 3, 'en_cours'], + ]) + }) + + it('attend la récupération d’une collection complète et la compte', () => { + const j = avecCollections() + const complete = etat({ progression_collections: progression(3, 0) }) + expect(quetesDeCollection(j, complete)[0]?.suivi.etat).toBe('a_recuperer') + expect(recompensesARecuperer(j, complete)).toBe(1) + expect(quetesDeCollection(j, { ...complete, paliers_recuperes: [PREFIXES] })[0]?.suivi.etat).toBe('recupere') + }) + + it('ne montre rien que le serveur n’a pas compté (fascicule verrouillé)', () => { + expect(quetesDeCollection(avecCollections(), etat())).toEqual([]) + expect(quetesDeCollection(avecCollections(), null)).toEqual([]) + }) +}) diff --git a/web/src/services/quetes.ts b/web/src/services/quetes.ts index e28fcc9..8287e8f 100644 --- a/web/src/services/quetes.ts +++ b/web/src/services/quetes.ts @@ -3,7 +3,7 @@ // récupérer. Le serveur fait foi : un jalon n'est « à récupérer » que s'il le // dit atteint. -import type { Etat, Jalon, Palier, QueteLongCours } from '../contrat' +import type { Collection, CritereCollection, Etat, Jalon, Palier, QueteLongCours } from '../contrat' import { fasciculesDebloques } from './deblocage' import type { CatalogueJeu, FasciculeParu } from './jeu' @@ -98,6 +98,41 @@ export function quetesAuLongCours(jeu: CatalogueJeu, etat: Etat | null): QueteLo }) } +/** Intitulés des quêtes de collection (App#90), dans l'ordre d'affichage. */ +export const CRITERES: Record = { + prefixes: 'Tous les préfixes', + suffixes: 'Tous les suffixes', + mots_communs: 'Tous les mots communs', + mots_peu_communs: 'Tous les mots peu communs', + mots_rares: 'Tous les mots rares', +} +const ORDRE_CRITERES = Object.keys(CRITERES) as CritereCollection[] + +export interface QueteCollection { + collection: Collection + fascicule: FasciculeParu + /** Comme un palier : son seuil est le total, sa récompense celle de la collection. */ + suivi: PalierSuivi +} + +/** + * Quêtes de collection (App#90) : tous les préfixes, tous les suffixes, tous les + * mots d'une rareté d'un fascicule. Le serveur compte, et ne renvoie que les + * fascicules débloqués : un fascicule verrouillé n'expose aucun total (ADR 0069). + * La récompense se récupère comme un palier (ADR 0042). + */ +export function quetesDeCollection(jeu: CatalogueJeu, etat: Etat | null): QueteCollection[] { + const recuperes = new Set(etat?.paliers_recuperes ?? []) + return (etat?.progression_collections ?? []).flatMap((p) => { + const fascicule = jeu.fascicules.find((f) => f.manifeste.id === p.fascicule_id) + const collection = fascicule?.manifeste.collections?.find((c) => c.id === p.palier_id) + if (!fascicule || !collection || p.total === 0) return [] + const palier: Palier = { id: collection.id, seuil: p.total, encre: collection.encre, sabliers: collection.sabliers } + const etatPalier: EtatJalon = recuperes.has(collection.id) ? 'recupere' : p.progression >= p.total ? 'a_recuperer' : 'en_cours' + return [{ collection, fascicule, suivi: { palier, avancee: Math.min(p.progression, p.total), etat: etatPalier } }] + }).sort((a, b) => a.fascicule.numero - b.fascicule.numero || ORDRE_CRITERES.indexOf(a.collection.critere) - ORDRE_CRITERES.indexOf(b.collection.critere)) +} + /** Récompenses qui attendent le joueur, jalons et paliers : le compteur de la navigation. */ export function recompensesARecuperer(jeu: CatalogueJeu | null, etat: Etat | null): number { if (!jeu || !etat) return 0 @@ -105,5 +140,6 @@ export function recompensesARecuperer(jeu: CatalogueJeu | null, etat: Etat | nul const cartes = etat.carte_de_lecteur?.a_recuperer ?? 0 const cadeau = etat.cadeau_de_bienvenue && !etat.cadeau_de_bienvenue.recupere ? 1 : 0 const initiales = (etat.quetes_initiales ?? []).filter((q) => q.faite && !q.recuperee).length - return quetesDeFascicule(jeu, etat).filter((q) => q.etat === 'a_recuperer').length + paliers + cartes + cadeau + initiales + const collections = quetesDeCollection(jeu, etat).filter((q) => q.suivi.etat === 'a_recuperer').length + return quetesDeFascicule(jeu, etat).filter((q) => q.etat === 'a_recuperer').length + paliers + cartes + cadeau + initiales + collections }