From a2abf5a6cc7a26ffac7506825278e8455d0ddb66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:40:07 +0000 Subject: [PATCH] feat(fascicules): declare per-fascicule collection quests A fascicule may now declare `collections`: all its prefixes, all its suffixes, all its common, uncommon or rare words, each with an ink and hourglass reward claimed like a palier (ADR 0042). Members are computed once (outils/src/collections.rs), never counting a legendary nor a word that needs one (ADR 0043): the validator checks them, the compilation publishes them in the manifest. New controls: fascicule.collection_doublon (criterion twice, or a palier id already used by a collection or a long-running quest), fascicule.collection_trop_petite (fewer than 3 members) and fascicule.collection_sans_recompense. All eleven fascicules declare their quests (rarity, not difficulty, which ADR 0047 keeps hidden); the three below three members are left out. Refs EtymoLogique/App#90 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0151stDRaZV3ScbhwvwjrfQ7 --- README.md | 1 + ludique/fascicules/fsc_82469jsr.json | 32 ++++++ ludique/fascicules/fsc_8ejarvyg.json | 32 ++++++ ludique/fascicules/fsc_bgjg6y6r.json | 32 ++++++ ludique/fascicules/fsc_fr1wg423.json | 32 ++++++ ludique/fascicules/fsc_gxbf2823.json | 32 ++++++ ludique/fascicules/fsc_j3kv21ck.json | 32 ++++++ ludique/fascicules/fsc_mbgjjfb8.json | 26 +++++ ludique/fascicules/fsc_q3rg1crf.json | 32 ++++++ ludique/fascicules/fsc_qetnbxnc.json | 32 ++++++ ludique/fascicules/fsc_s4fs9351.json | 26 +++++ ludique/fascicules/fsc_s549mgdc.json | 26 +++++ outils/src/collections.rs | 142 +++++++++++++++++++++++++++ outils/src/compilation.rs | 12 +++ outils/src/controles.rs | 64 ++++++++++++ outils/src/main.rs | 1 + outils/src/tests/compilation.rs | 43 ++++++++ outils/src/tests/validation.rs | 62 ++++++++++++ schemas/fascicule.schema.json | 15 +++ 19 files changed, 674 insertions(+) create mode 100644 outils/src/collections.rs diff --git a/README.md b/README.md index 7fd05f6f..0d754f42 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ La page montre les notes internes et les faits non relus. Elle reste locale : `r - **Briques** : une par unité, qui affiche la forme canonique et un sens glosé de son unité. - **Recettes** : exactement deux emplacements (on ne fusionne jamais plus de deux briques) ; les emplacements suivent les parties et acceptent la brique de chaque partie ; aucune suite de briques ne mène à deux recettes ; aucune analyse non retenue ; une définition pour le mot résultat. - **Fascicules** : numéros uniques ; un mot n’est le résultat que d’un fascicule ; un résultat est un mot avec une recette ; un ingrédient a une brique ; autonomie (tout ingrédient est déclaré, et publié dans ce fascicule ou un précédent) ; graines (ADR 0030) : briques existantes, déclarées par le fascicule, sans doublon, de 1 à 5 exemplaires ; jalons (ADR 0038) : identifiants uniques, découvertes strictement croissantes, pli fait de préfixes et de suffixes déclarés par le fascicule, un pli ou des sabliers, au moins deux jalons, le deuxième débloquant le fascicule suivant (`fascicule.jalon_deblocage`, [ADR 0068](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0068-deblocage-progressif-des-fascicules.md)) ; chaque résultat porte une `difficulte` (`facile`, `moyen`, `difficile`), qu’un ingrédient n’a jamais (ADR 0047) ; en avertissement : taille de 20 à 30 mots (ADR 0022), sauf pour les dix fascicules de lancement (ADR 0054), un tiers de mots de chaque difficulté à un mot près, légendaires exclus (`fascicule.tiers`, ADR 0047), à partir du onzième fascicule (ADR 0059), ingrédients inutiles, chronologie (les fascicules de lancement peuvent paraître le même jour, ADR 0054), règle d’amorçage (au moins trois recettes réalisables avec les seules graines, toutes ensemble ; un premier fascicule sans graines) ; ni jalon ni graine légendaires (`fascicule.jalon_legendaire`, `fascicule.graine_legendaire`) ; au moins une brique légendaire par fascicule (`fascicule.legendaire_absente`, ADR 0043), avertissement, erreur en mode publication. +- **Quêtes de collection** (App#90) : un critère de la liste fermée (`prefixes`, `suffixes`, `mots_communs`, `mots_peu_communs`, `mots_rares`) au plus une fois par fascicule, un identifiant de palier jamais repris, ni par une autre collection ni par une quête au long cours (`fascicule.collection_doublon`) ; au moins trois membres, hors légendaires et hors mots qui en dépendent (`fascicule.collection_trop_petite`) ; une récompense (`fascicule.collection_sans_recompense`). La compilation publie les membres dans le manifeste (`collections`) : les briques du pli du fascicule, reprises comprises, ou ses mots de cette rareté (ADR 0040). - **Exclusions** : aucune ne contredit une recette. - **Quêtes et cadeau de bienvenue** ([ADR 0045](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0045-quetes-du-jour-carte-de-lecteur-et-quetes-au-long-cours.md), [0046](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0046-demarrage-genereux-cadeau-de-bienvenue-et-quetes-initiales.md), `ludique/quetes.json`) : conditions en liste fermée (langue, famille, transformation, ricochets, lecteur, première légendaire) ; identifiants uniques ; langue et type de transformation existants ; seuils des paliers strictement croissants et chaque palier donne quelque chose ; « première légendaire » est la seule quête qui touche aux légendaires, à un seul palier de seuil 1 ; le cadeau ne donne que des briques de fascicules, jamais une légendaire. Les valeurs de récompense sont des valeurs de départ, **à simuler**. - **Trompe-l’œil** ([ADR 0052](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0052-fusions-trompeuses-expliquees.md)) : les deux briques existent et sont publiées dans ce fascicule ou un précédent, aucune n’est légendaire (`trompe_l_oeil.paire`, `trompe_l_oeil.legendaire`) ; au plus un par paire ordonnée (`trompe_l_oeil.doublon`) ; ni la paire ni son inverse n’est une recette, et la paire n’est pas une exclusion (`trompe_l_oeil.recette`) ; aucun remplacement d’une brique par un homographe ne donne une recette, car le serveur le calcule déjà (`trompe_l_oeil.homographe_jouable`) ; si `mot_id` est renseigné, sa composition ne mobilise pas à la fois les deux unités (`trompe_l_oeil.origine`) et l’explication courte ne le nomme pas (`trompe_l_oeil.divulgation`) ; au moins une source (`trompe_l_oeil.sources`). Avec `--publication`, chaque trompe-l’œil du fascicule est relu (`publication.trompe_l_oeil`). diff --git a/ludique/fascicules/fsc_82469jsr.json b/ludique/fascicules/fsc_82469jsr.json index 5a9f513b..a40ca267 100644 --- a/ludique/fascicules/fsc_82469jsr.json +++ b/ludique/fascicules/fsc_82469jsr.json @@ -530,5 +530,37 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_3kswngy7", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_b1a51y00", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_hxxt1kjx", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_kf4gv151", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + }, + { + "id": "pal_g7mh1dvv", + "critere": "mots_rares", + "encre": 40, + "sabliers": 1 + } + ], "note_interne": "Mots nouveaux autour du corps et de la médecine. Fermeture sans exclusion : toute combinaison attestée est publiée avec sa recette. Faits en brouillon, localisations à renseigner. Fermeture vérifiée à la main sur toutes les briques publiées : à relire. Silhouettes à dessiner." } diff --git a/ludique/fascicules/fsc_8ejarvyg.json b/ludique/fascicules/fsc_8ejarvyg.json index 4921d341..afb94a82 100644 --- a/ludique/fascicules/fsc_8ejarvyg.json +++ b/ludique/fascicules/fsc_8ejarvyg.json @@ -724,5 +724,37 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_1qkw9kby", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_n3sa68jg", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_xtzbg8p5", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_ah4f8nsw", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + }, + { + "id": "pal_hnn8hekj", + "critere": "mots_rares", + "encre": 40, + "sabliers": 1 + } + ], "note_interne": "50 mots nouveaux autour de la pierre, de l’inscription et du pouvoir. Faits en brouillon, localisations à renseigner. Fermeture vérifiée à la main sur toutes les briques publiées : à relire. Silhouettes à dessiner." } diff --git a/ludique/fascicules/fsc_bgjg6y6r.json b/ludique/fascicules/fsc_bgjg6y6r.json index 0c90b845..a60e2a27 100644 --- a/ludique/fascicules/fsc_bgjg6y6r.json +++ b/ludique/fascicules/fsc_bgjg6y6r.json @@ -840,5 +840,37 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_akpt12cm", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_3pwfbdja", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_8pa2zrvd", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_7vygpsfj", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + }, + { + "id": "pal_s48z6abe", + "critere": "mots_rares", + "encre": 40, + "sabliers": 1 + } + ], "note_interne": "50 mots nouveaux autour des noms (-onyme, -onymie) et du suffixe -ique. Faits en brouillon, localisations à renseigner. Fermeture vérifiée à la main sur toutes les briques publiées : à relire. Silhouettes à dessiner." } diff --git a/ludique/fascicules/fsc_fr1wg423.json b/ludique/fascicules/fsc_fr1wg423.json index 0f063d02..e8df12d3 100644 --- a/ludique/fascicules/fsc_fr1wg423.json +++ b/ludique/fascicules/fsc_fr1wg423.json @@ -404,5 +404,37 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_15vt26h8", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_e245tk3n", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_j4y80ey9", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_er6h2t1w", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + }, + { + "id": "pal_q9cf905c", + "critere": "mots_rares", + "encre": 40, + "sabliers": 1 + } + ], "note_interne": "Mots repris des fascicules d’essai Bêta et Bêta 2 et mots nouveaux, 50 mots. Faits en brouillon, localisations à renseigner. Silhouettes et jaquette à dessiner." } diff --git a/ludique/fascicules/fsc_gxbf2823.json b/ludique/fascicules/fsc_gxbf2823.json index a49f3aef..2ff70fc4 100644 --- a/ludique/fascicules/fsc_gxbf2823.json +++ b/ludique/fascicules/fsc_gxbf2823.json @@ -408,5 +408,37 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_z5vz0f02", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_drhk0ny7", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_w7d3vbqd", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_j3ys9wsy", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + }, + { + "id": "pal_xhbrcga8", + "critere": "mots_rares", + "encre": 40, + "sabliers": 1 + } + ], "note_interne": "Mots repris des fascicules d’essai Bêta et Bêta 2 et mots nouveaux, 50 mots. Faits en brouillon, localisations à renseigner. Silhouettes et jaquette à dessiner." } diff --git a/ludique/fascicules/fsc_j3kv21ck.json b/ludique/fascicules/fsc_j3kv21ck.json index f493675b..6a5cf9f7 100644 --- a/ludique/fascicules/fsc_j3kv21ck.json +++ b/ludique/fascicules/fsc_j3kv21ck.json @@ -627,5 +627,37 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_bwhht2pv", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_8cjdez98", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_9r3xn0z2", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_54nwc90d", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + }, + { + "id": "pal_jp2yehb6", + "critere": "mots_rares", + "encre": 40, + "sabliers": 1 + } + ], "note_interne": "Mots nouveaux autour du voyage, du retour et des doctrines. Fermeture sans exclusion : toute combinaison attestée est publiée avec sa recette, et apathie, anomie et théiste comblent des trous de fermeture des fascicules précédents. Faits en brouillon, localisations à renseigner. Fermeture vérifiée à la main : à relire. Silhouettes à dessiner." } diff --git a/ludique/fascicules/fsc_mbgjjfb8.json b/ludique/fascicules/fsc_mbgjjfb8.json index 881a7922..0afe888f 100644 --- a/ludique/fascicules/fsc_mbgjjfb8.json +++ b/ludique/fascicules/fsc_mbgjjfb8.json @@ -349,5 +349,31 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_me8kxa47", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_p8dfg0pf", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_2aenyapb", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + }, + { + "id": "pal_9p4hgr4b", + "critere": "mots_rares", + "encre": 40, + "sabliers": 1 + } + ], "note_interne": "Parution au 1ᵉʳ décembre 2026, 19 jours après le n° 10 : choix éditorial du 12 octobre 2026. Nouvelles briques : -ode « chemin », -opsie « vue », nécro- « mort ». Tous leurs mots croisés attestés par Wikidata sont publiés, y compris photopsie, polyopsie, zoopsie, bradyopsie, téléopsie, nécronyme, nécrocratie et nécropsie (choix éditorial du 12 octobre 2026). Palinopsie, sur palin- légendaire, est le mot légendaire du fascicule. Trous de fermeture des fascicules précédents comblés : autiste, graphique, graphisme, graphiste, éponymie, éparchie, hyperalgie, aéromancie. Restent ouverts, attestés par Wikidata : endonyme, exonyme, synarchie, stéréotomie, toparque, exarque, éparque, mononyme, hyponymie, hyperonymie, théonymie, zoonymie, ethnonymie, chrononyme, hipparque, hypothèque. Trompe-l’œil à examiner : géode et épode, qui ne viennent pas de hodós, et hydropsie, du grec hudrôps. Nécrophile écarté par une exclusion (offensant). 31 mots, un de plus que le plafond : choix éditorial du 12 octobre 2026, pour garder les tiers. Faits en brouillon : Wikidata ne porte aucune composition de ces mots." } diff --git a/ludique/fascicules/fsc_q3rg1crf.json b/ludique/fascicules/fsc_q3rg1crf.json index 4579c530..3b7591c3 100644 --- a/ludique/fascicules/fsc_q3rg1crf.json +++ b/ludique/fascicules/fsc_q3rg1crf.json @@ -681,5 +681,37 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_79r62528", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_4bn0t3gd", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_c9fh455m", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_8s05e05p", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + }, + { + "id": "pal_gc34mkrt", + "critere": "mots_rares", + "encre": 40, + "sabliers": 1 + } + ], "note_interne": "50 mots nouveaux autour des mesures, des caractères et des nombres. Faits en brouillon, localisations à renseigner. Fermeture vérifiée à la main sur toutes les briques publiées : à relire. Les mots de masse (kilogramme…) sont exclus : la brique -gramme publiée signifie « écrit », pas l’unité. Silhouettes à dessiner." } diff --git a/ludique/fascicules/fsc_qetnbxnc.json b/ludique/fascicules/fsc_qetnbxnc.json index bf178984..26257acc 100644 --- a/ludique/fascicules/fsc_qetnbxnc.json +++ b/ludique/fascicules/fsc_qetnbxnc.json @@ -647,5 +647,37 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_ha4s1xct", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_hy6jdbs7", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_mm8tqwfw", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_2vak1vjj", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + }, + { + "id": "pal_af3t5rmy", + "critere": "mots_rares", + "encre": 40, + "sabliers": 1 + } + ], "note_interne": "50 mots nouveaux autour des langues, des récits et des peuples. Faits en brouillon, localisations à renseigner. Fermeture vérifiée à la main sur toutes les briques publiées : à relire. Silhouettes à dessiner." } diff --git a/ludique/fascicules/fsc_s4fs9351.json b/ludique/fascicules/fsc_s4fs9351.json index 31187a6f..b2505cd7 100644 --- a/ludique/fascicules/fsc_s4fs9351.json +++ b/ludique/fascicules/fsc_s4fs9351.json @@ -432,5 +432,31 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_hqwbed3m", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_b1ed1kz2", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_qhmv4g2f", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_m7vftatz", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + } + ], "note_interne": "Mots repris des fascicules d’essai Bêta et Bêta 2 et mots nouveaux, 50 mots. Faits en brouillon, localisations à renseigner. Silhouettes et jaquette à dessiner." } diff --git a/ludique/fascicules/fsc_s549mgdc.json b/ludique/fascicules/fsc_s549mgdc.json index c648bae4..f77e1397 100644 --- a/ludique/fascicules/fsc_s549mgdc.json +++ b/ludique/fascicules/fsc_s549mgdc.json @@ -396,5 +396,31 @@ "sabliers": 1 } ], + "collections": [ + { + "id": "pal_qtrtr42t", + "critere": "prefixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_n6s2p0py", + "critere": "suffixes", + "encre": 20, + "sabliers": 1 + }, + { + "id": "pal_t76b1393", + "critere": "mots_communs", + "encre": 20, + "sabliers": 0 + }, + { + "id": "pal_0ey6bhh9", + "critere": "mots_peu_communs", + "encre": 30, + "sabliers": 1 + } + ], "note_interne": "Mots repris des fascicules d’essai Bêta et Bêta 2 et mots nouveaux, 50 mots. Faits en brouillon, localisations à renseigner. Silhouettes et jaquette à dessiner." } diff --git a/outils/src/collections.rs b/outils/src/collections.rs new file mode 100644 index 00000000..a50af79f --- /dev/null +++ b/outils/src/collections.rs @@ -0,0 +1,142 @@ +//! Quêtes de collection par fascicule (App#90) : tous les préfixes, tous les +//! suffixes, tous les mots d'une rareté. Le fascicule déclare la quête et sa +//! récompense ; ses membres se calculent ici, une seule fois, pour le +//! validateur et pour la compilation, qui les publie dans le manifeste. +//! +//! Jamais une légendaire, ni un mot qui en exige une (ADR 0043) : une quête +//! ne compte que ce que le joueur peut voir. + +use std::collections::BTreeSet; + +use serde_json::Value; + +use crate::chargement::Catalogue; +use crate::json::{liste, texte}; + +/// Une quête de moins de cartes n'est pas déclarée. +pub const MEMBRES_MIN: usize = 3; + +const RARETES: [&str; 4] = ["commune", "peu_commune", "rare", "legendaire"]; + +fn rang(rarete: &str) -> usize { + RARETES.iter().position(|r| *r == rarete).unwrap_or(0) +} + +fn brique_de<'a>(c: &'a Catalogue, unite: &str) -> Option<(&'a String, &'a Value)> { + c.briques + .iter() + .find(|(_, b)| texte(&b["unite_id"]) == unite) +} + +fn legendaire(c: &Catalogue, unite: &str) -> bool { + brique_de(c, unite).is_some_and(|(_, b)| b["rarete"] == "legendaire") +} + +/// Recettes qui donnent ce mot. +fn recettes_de<'a>(c: &'a Catalogue, mot: &str) -> Vec<&'a Value> { + c.recettes + .values() + .filter(|r| { + c.compositions + .get(texte(&r["composition_id"])) + .is_some_and(|comp| texte(&comp["mot_id"]) == mot) + }) + .collect() +} + +/// Rareté d'une brique au pli ; un mot posé tel quel compte comme commun. +fn rarete_brique(c: &Catalogue, brique: &str) -> &'static str { + let Some(b) = c.briques.get(brique) else { + return "commune"; + }; + let mot = c + .unites + .get(texte(&b["unite_id"])) + .is_some_and(|u| u["nature"] == "mot"); + if mot { + return "commune"; + } + RARETES[rang(texte(&b["rarete"]))] +} + +/// Rareté d'un mot (ADR 0040), miroir de `rareteMot` (App) : la plus haute +/// rareté des briques qu'exige sa recette la plus accessible ; pour un +/// emplacement à variantes, la moins rare compte. +pub fn rarete_mot(c: &Catalogue, mot: &str) -> &'static str { + recettes_de(c, mot) + .iter() + .map(|r| { + liste(&r["emplacements"]) + .iter() + .map(|e| { + liste(&e["briques_acceptees"]) + .iter() + .map(|b| rang(rarete_brique(c, texte(b)))) + .min() + .unwrap_or(0) + }) + .max() + .unwrap_or(0) + }) + .min() + .map_or("commune", |r| RARETES[r]) +} + +/// Un mot qui exige une légendaire reste invisible jusqu'à sa découverte. +fn depend_de_legendaire(c: &Catalogue, mot: &str) -> bool { + recettes_de(c, mot).iter().any(|r| { + liste(&r["emplacements"]).iter().any(|e| { + liste(&e["briques_acceptees"]) + .iter() + .filter_map(|b| c.briques.get(texte(b))) + .any(|b| b["rarete"] == "legendaire") + }) + }) +} + +/// Membres d'une quête de collection, triés : des identifiants de brique pour +/// les préfixes et les suffixes (les briques du pli du fascicule, reprises +/// comprises), des identifiants d'unité pour les mots. Vide pour un critère inconnu. +pub fn membres(c: &Catalogue, fid: &str, critere: &str) -> Vec { + let Some(fsc) = c.fascicules.get(fid) else { + return Vec::new(); + }; + let unites = liste(&fsc["unites"]); + let nature = |u: &str| c.unites.get(u).map_or("", |u| texte(&u["nature"])); + let mut ids: BTreeSet = BTreeSet::new(); + match critere { + "prefixes" | "suffixes" => { + let voulue = &critere[..critere.len() - 1]; + for u in unites.iter().map(|u| texte(&u["unite_id"])) { + if nature(u) == voulue + && !legendaire(c, u) + && let Some((bid, _)) = brique_de(c, u) + { + ids.insert(bid.clone()); + } + } + } + "mots_communs" | "mots_peu_communs" | "mots_rares" => { + let voulue = match critere { + "mots_communs" => "commune", + "mots_peu_communs" => "peu_commune", + _ => "rare", + }; + for u in unites + .iter() + .filter(|u| u["role"] == "resultat") + .map(|u| texte(&u["unite_id"])) + { + if nature(u) == "mot" + && !legendaire(c, u) + && !depend_de_legendaire(c, u) + && rarete_mot(c, u) == voulue + { + ids.insert(u.to_owned()); + } + } + } + _ => {} + } + ids.into_iter().collect() +} diff --git a/outils/src/compilation.rs b/outils/src/compilation.rs index 738515b6..f983b7a4 100644 --- a/outils/src/compilation.rs +++ b/outils/src/compilation.rs @@ -27,6 +27,7 @@ use sha2::{Digest, Sha256}; use unicode_normalization::UnicodeNormalization; use crate::chargement::{Catalogue, Table}; +use crate::collections; use crate::homographes; use crate::json::{CANONIQUE, a_cle, ecrire, liste, texte, vrai}; use crate::medias::{self, CleMedia}; @@ -673,6 +674,14 @@ impl<'a> Compilateur<'a> { json!({"id": j["id"], "decouvertes": j["decouvertes"], "pli": pli, "sabliers": j["sabliers"]}) }) .collect(); + // Quêtes de collection (App#90) : leurs membres, hors légendaires, calculés une fois ici. + let collections: Vec = liste(&fsc["collections"]) + .iter() + .map(|q| { + let membres = collections::membres(c, fid, texte(&q["critere"])); + json!({"id": q["id"], "critere": q["critere"], "encre": q["encre"], "sabliers": q["sabliers"], "membres": membres}) + }) + .collect(); let mut graines: Vec<&Value> = liste(&fsc["graines"]).iter().collect(); graines.sort_by(|a, b| texte(&a["brique_id"]).cmp(texte(&b["brique_id"]))); @@ -710,6 +719,9 @@ impl<'a> Compilateur<'a> { if !homographes.is_empty() { manifeste["homographes"] = json!(homographes); } + if !collections.is_empty() { + manifeste["collections"] = json!(collections); + } if vrai(&fsc["test"]) { manifeste["test"] = Value::Bool(true); } diff --git a/outils/src/controles.rs b/outils/src/controles.rs index 5b059688..5c0ceb64 100644 --- a/outils/src/controles.rs +++ b/outils/src/controles.rs @@ -1191,6 +1191,19 @@ impl<'a> Controleur<'a> { let c = self.c; let mut numeros: HashMap<(bool, i64), &str> = HashMap::new(); let mut jalons: HashMap = HashMap::new(); + // Identifiants de palier déjà pris : ceux des quêtes au long cours, puis ceux des collections. + let mut collections: HashMap = c + .quetes + .iter() + .flat_map(|q| liste(&q["quetes"])) + .flat_map(|q| liste(&q["paliers"])) + .map(|p| { + ( + texte(&p["id"]).to_owned(), + crate::chargement::QUETES.to_owned(), + ) + }) + .collect(); let mut resultats: HashMap<(bool, &str), &str> = HashMap::new(); for (fid, fsc) in &c.fascicules { let test = vrai(&fsc["test"]); @@ -1263,6 +1276,7 @@ impl<'a> Controleur<'a> { } self.graines(fid, fsc, &vus); self.jalons(fid, fsc, &vus, &mut jalons); + self.collections(fid, fsc, &mut collections); } for (fid, fsc) in &c.fascicules { @@ -1480,6 +1494,56 @@ impl<'a> Controleur<'a> { } } + /// Quêtes de collection (App#90) : un identifiant de palier jamais repris, un + /// critère au plus une fois par fascicule, au moins trois membres hors + /// légendaires, et une récompense. + fn collections(&mut self, fid: &str, fsc: &Value, vus: &mut HashMap) { + let mut criteres: HashSet<&str> = HashSet::new(); + for quete in liste(&fsc["collections"]) { + let id = texte(&quete["id"]); + let critere = texte(&quete["critere"]); + if let Some(autre) = vus.get(id) { + self.erreur( + "fascicule.collection_doublon", + format!("Le palier {id} est déjà déclaré par {autre}."), + fid, + None, + ); + } + vus.insert(id.to_owned(), fid.to_owned()); + if !criteres.insert(critere) { + self.erreur( + "fascicule.collection_doublon", + format!("La quête de collection « {critere} » est déclarée deux fois."), + fid, + None, + ); + } + let membres = crate::collections::membres(self.c, fid, critere).len(); + if membres < crate::collections::MEMBRES_MIN { + self.erreur( + "fascicule.collection_trop_petite", + format!( + "La quête de collection « {critere} » ne compte que {membres} carte(s), hors légendaires : il en faut au moins {}.", + crate::collections::MEMBRES_MIN + ), + fid, + None, + ); + } + if quete["encre"].as_i64().unwrap_or(0) == 0 + && quete["sabliers"].as_i64().unwrap_or(0) == 0 + { + self.erreur( + "fascicule.collection_sans_recompense", + format!("La quête de collection {id} ne donne rien."), + fid, + None, + ); + } + } + } + /// Quêtes au long cours et cadeau de bienvenue (ADR 0045, 0046) : références, /// paliers croissants, aucune légendaire comptée ni donnée. fn quetes(&mut self) { diff --git a/outils/src/main.rs b/outils/src/main.rs index 0fa5a9a6..1943e096 100644 --- a/outils/src/main.rs +++ b/outils/src/main.rs @@ -10,6 +10,7 @@ use clap::{Parser, Subcommand}; mod candidats; mod chargement; +mod collections; mod compilation; mod controles; mod homographes; diff --git a/outils/src/tests/compilation.rs b/outils/src/tests/compilation.rs index 60adeb85..08a30613 100644 --- a/outils/src/tests/compilation.rs +++ b/outils/src/tests/compilation.rs @@ -529,6 +529,49 @@ fn manifeste_publie_les_jalons() { assert_eq!(jalons[1]["sabliers"], 2); } +#[test] +fn manifeste_publie_les_quetes_de_collection_sans_legendaire() { + let (manifeste, _) = manifeste_apres(|depot| { + depot.modifier(depot.fascicule(), |f| { + f["collections"] = json!([ + {"id": "pal_00000c01", "critere": "prefixes", "encre": 20, "sabliers": 1}, + {"id": "pal_00000c02", "critere": "mots_communs", "encre": 20, "sabliers": 0}, + ]); + }); + }); + let collections = liste(&manifeste["collections"]); + assert_eq!(collections.len(), 2); + let cles: BTreeSet<&str> = collections[0] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + cles, + BTreeSet::from(["id", "critere", "encre", "sabliers", "membres"]) + ); + let prefixes: Vec<&str> = liste(&collections[0]["membres"]) + .iter() + .map(texte) + .collect(); + assert!(!prefixes.is_empty()); + assert!(prefixes.iter().all(|b| b.starts_with("brq_"))); + // La brique légendaire du jeu d'exemples n'y figure jamais (ADR 0043). + assert!(!prefixes.contains(&Depot::new().id_brique("étymo").as_str())); + let mots: Vec<&str> = liste(&collections[1]["membres"]) + .iter() + .map(texte) + .collect(); + assert!(!mots.is_empty() && mots.iter().all(|m| m.starts_with("unt_"))); +} + +#[test] +fn sans_quete_de_collection_le_manifeste_n_en_porte_pas() { + let (manifeste, _) = manifeste_apres(|_| {}); + assert!(manifeste.get("collections").is_none()); +} + #[test] fn manifeste_porte_les_donnees_de_jugement_des_ricochets() { let (depot, sortie, parutions) = essai(); diff --git a/outils/src/tests/validation.rs b/outils/src/tests/validation.rs index c81cd67a..aa90bebe 100644 --- a/outils/src/tests/validation.rs +++ b/outils/src/tests/validation.rs @@ -1821,3 +1821,65 @@ fn le_cadeau_ne_donne_que_des_briques_de_fascicules() { depot.ecrire_quetes(&quetes); assert!(a_erreur(&depot, "reference.cassee")); } + +// ------------------------------------------------------------ quêtes de collection (App#90) + +fn collections(depot: &Depot, quetes: serde_json::Value) { + depot.modifier(depot.fascicule(), |f| f["collections"] = quetes); +} + +#[test] +fn collection_doublon() { + let depot = Depot::new(); + collections( + &depot, + json!([ + {"id": "pal_00000c01", "critere": "suffixes", "encre": 20, "sabliers": 1}, + {"id": "pal_00000c02", "critere": "suffixes", "encre": 20, "sabliers": 1}, + ]), + ); + assert!(a_erreur(&depot, "fascicule.collection_doublon")); +} + +#[test] +fn collection_doublon_avec_un_palier_de_quete() { + let depot = Depot::new(); + let quetes = depot.quetes_valides(); + let palier = quetes["quetes"][0]["paliers"][0]["id"].clone(); + depot.ecrire_quetes(&quetes); + collections( + &depot, + json!([{"id": palier, "critere": "suffixes", "encre": 20, "sabliers": 1}]), + ); + assert!(a_erreur(&depot, "fascicule.collection_doublon")); +} + +#[test] +fn collection_trop_petite() { + let depot = Depot::new(); + collections( + &depot, + json!([{"id": "pal_00000c01", "critere": "mots_rares", "encre": 40, "sabliers": 1}]), + ); + assert!(a_erreur(&depot, "fascicule.collection_trop_petite")); +} + +#[test] +fn collection_sans_recompense() { + let depot = Depot::new(); + collections( + &depot, + json!([{"id": "pal_00000c01", "critere": "suffixes", "encre": 0, "sabliers": 0}]), + ); + assert!(a_erreur(&depot, "fascicule.collection_sans_recompense")); +} + +#[test] +fn collection_critere_inconnu() { + let depot = Depot::new(); + collections( + &depot, + json!([{"id": "pal_00000c01", "critere": "mots_difficiles", "encre": 20, "sabliers": 0}]), + ); + assert!(a_erreur(&depot, "schema")); +} diff --git a/schemas/fascicule.schema.json b/schemas/fascicule.schema.json index d9c09d83..f8a081ab 100644 --- a/schemas/fascicule.schema.json +++ b/schemas/fascicule.schema.json @@ -81,6 +81,21 @@ ] } }, + "collections": { + "description": "Quêtes de collection du fascicule (App#90) : tous ses préfixes, tous ses suffixes, tous ses mots d'une rareté. Les membres se calculent à la compilation, hors légendaires ; la récompense se récupère comme un palier (ADR 0042).", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "critere", "encre", "sabliers"], + "properties": { + "id": { "$ref": "communs.schema.json#/$defs/id_palier" }, + "critere": { "enum": ["prefixes", "suffixes", "mots_communs", "mots_peu_communs", "mots_rares"] }, + "encre": { "type": "integer", "minimum": 0, "maximum": 200 }, + "sabliers": { "type": "integer", "minimum": 0, "maximum": 12 } + } + } + }, "note_interne": { "$ref": "communs.schema.json#/$defs/note_interne" } }, "if": { "properties": { "test": { "const": false } } },