Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ La page montre les notes internes et les faits non relus. Elle reste locale : `r
| Fichier | Contenu |
|---|---|
| `r/<sha256>.json` | Ressources : carte de chaque unité déclarée, recette, exclusions et trompe-l’œil d’un fascicule. Un trompe-l’œil publié n’emporte ni son fait, ni sa relecture, ni sa note : seulement la paire, le motif et les deux explications. |
| `f/<sha256>.json` | Manifeste de fascicule : identifiant, compteurs hors légendaires, briques tirables, graines, jalons, `ricochets` (dont, pour un mot dont une partie est elle-même un mot composé, `autres_coupes` : les découpes que le joueur doit trouver en plus de la préférée, ADR 0084 ; au-delà de quatre découpes, aucune n'est publiée), chemins des cartes et de leurs silhouettes, silhouettes des langues citées (`langues`), jaquette, recettes, exclusions et `ricochets` (sens affiché et classe de sens des briques, classe du sens littéral et coupes des mots, sans aucun texte ; ni légendaire ni mot qui en dépend, [ADR 0044](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0044-ricochets-reviser-et-maitriser-les-cartes.md)), `trompes_l_oeil` (chemin de la ressource) et `homographes` (groupes d’identifiants de briques de même forme, hors légendaires, [ADR 0052](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0052-fusions-trompeuses-expliquees.md)). Ces deux clés n’existent que si elles ne sont pas vides : un fascicule sans fausse piste ni homographe garde son empreinte. |
| `f/<sha256>.json` | Manifeste de fascicule : identifiant, compteurs hors légendaires, briques tirables, graines, jalons, `ricochets` (dont, pour chaque mot, `coupes` : toutes ses coupes, celles des parties qui sont elles-mêmes des mots composés comprises, que le joueur pose d'un coup, ADR 0084 ; au-delà de huit coupes, les seules frontières du premier niveau), chemins des cartes et de leurs silhouettes, silhouettes des langues citées (`langues`), jaquette, recettes, exclusions et `ricochets` (sens affiché et classe de sens des briques, classe du sens littéral et coupes des mots, sans aucun texte ; ni légendaire ni mot qui en dépend, [ADR 0044](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0044-ricochets-reviser-et-maitriser-les-cartes.md)), `trompes_l_oeil` (chemin de la ressource) et `homographes` (groupes d’identifiants de briques de même forme, hors légendaires, [ADR 0052](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0052-fusions-trompeuses-expliquees.md)). Ces deux clés n’existent que si elles ne sont pas vides : un fascicule sans fausse piste ni homographe garde son empreinte. |
| `a/<sha256>.svg` | Médias : silhouettes et jaquettes, nommées d’après l’empreinte de leurs octets, jamais d’après le mot ni l’identifiant (ADR 0025). |
| `parutions.json` | Calendrier des parutions, seul fichier modifiable : version, référentiels, quêtes au long cours et cadeau de bienvenue (si `ludique/quetes.json` existe), et pour chaque fascicule sa date et son manifeste. La tâche de parution en tire `public/index.json`. |

Expand Down
79 changes: 29 additions & 50 deletions outils/src/compilation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ use crate::medias::{self, CleMedia};

pub const FORMAT: i64 = 1;

/// Nombre de découpes que l'API accepte pour un mot (`DECOUPES_MAX`, crates/api/src/ricochets.rs).
const DECOUPES_MAX: usize = 4;
/// Nombre de coupes que l'API accepte dans une réponse (`COUPES_MAX`, crates/api/src/ricochets.rs).
const COUPES_MAX: usize = 8;

/// Champs éditoriaux jamais publiés (modèle de données, carte de répartition).
pub const CHAMPS_PRIVES: [&str; 2] = ["note_interne", "etat_relecture"];
Expand Down Expand Up @@ -413,25 +413,24 @@ impl<'a> Compilateur<'a> {
.collect()
}

/// Toutes les découpes justes d'un mot, la préférée d'abord, positions relatives à sa forme (ADR 0084).
/// Toutes les coupes d'un mot, chaînage compris, positions relatives à sa forme (ADR 0084).
///
/// Une partie qui est elle-même un mot composé peut se couper à son tour : *étymologiste* se coupe
/// en *étymologie · iste*, puis *étymologie* en *étymo · logie*, soit `[10]` et `[5, 10]`. Chaque
/// partie compte pour une découpe indépendante, d'où toutes les combinaisons. Une partie élidée
/// (*biologie* dans *biologiste*) est bornée par son segment : une coupe au-delà n'existe pas dans
/// le mot. Vide : le mot ne se coupe pas.
fn decoupes(&self, mot_id: &str, vus: &[&str]) -> Vec<Vec<i64>> {
/// Une partie qui est elle-même un mot composé se coupe à son tour : *étymologiste* est
/// *étymologie · iste*, et *étymologie* est *étymo · logie*, d'où les coupes `[5, 10]`. Le joueur les
/// pose toutes d'un coup, et seule cette coupe complète est juste. Une partie élidée (*biologie* dans
/// *biologiste*) est bornée par son segment : une coupe au-delà n'existe pas dans le mot. Vide : le
/// mot ne se coupe pas.
fn coupes_completes(&self, mot_id: &str, vus: &[&str]) -> Vec<i64> {
let Some(composition) = self.composition_preferee(mot_id) else {
return Vec::new();
};
let base = self.coupes(composition);
if base.is_empty() {
return Vec::new();
let mut coupes = self.coupes(composition);
if coupes.is_empty() {
return coupes;
}
let mut parties: Vec<&Value> = liste(&composition["parties"]).iter().collect();
parties.sort_by_key(|p| p["rang"].as_i64());
let vus: Vec<&str> = vus.iter().copied().chain([mot_id]).collect();
let mut toutes = vec![base];
for partie in parties {
let (Some(debut), Some(fin)) = (
partie["segment_debut"].as_i64(),
Expand All @@ -443,36 +442,16 @@ impl<'a> Compilateur<'a> {
if vus.contains(&unite) {
continue;
}
// Les façons de couper cette partie, ramenées dans le mot entier.
let mut options: Vec<Vec<i64>> = Vec::new();
for relatives in self.decoupes(unite, &vus) {
let ramenees: Vec<i64> = relatives
coupes.extend(
self.coupes_completes(unite, &vus)
.into_iter()
.filter(|c| *c < fin - debut)
.map(|c| c + debut)
.collect();
if !ramenees.is_empty() && !options.contains(&ramenees) {
options.push(ramenees);
}
}
let mut suite = toutes.clone();
for existante in &toutes {
for option in &options {
let mut fusion: Vec<i64> = existante.iter().chain(option).copied().collect();
fusion.sort_unstable();
fusion.dedup();
if !suite.contains(&fusion) {
suite.push(fusion);
}
}
}
toutes = suite;
.map(|c| c + debut),
);
}
// La préférée d'abord, puis les autres par nombre de coupes croissant.
let base = toutes.remove(0);
toutes.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
toutes.insert(0, base);
toutes
coupes.sort_unstable();
coupes.dedup();
coupes
}

/// Les deux briques de la composition préférée, préfixe puis suffixe, quand le mot s'assemble
Expand Down Expand Up @@ -524,18 +503,18 @@ impl<'a> Compilateur<'a> {
if !resultats.contains(uid) || self.c.unites[uid]["nature"] != "mot" {
continue;
}
let coupes = self
.composition_preferee(uid)
.map(|c| self.coupes(c))
.unwrap_or_default();
// Toutes les coupes du mot, parties composées comprises (ADR 0084). Au-delà de ce que l'API
// accepte pour une réponse, seules les frontières du premier niveau sont publiées.
let complete = self.coupes_completes(uid, &[]);
let coupes = if complete.len() > COUPES_MAX {
self.composition_preferee(uid)
.map(|c| self.coupes(c))
.unwrap_or_default()
} else {
complete
};
let assemblage = self.assemblage(uid, &briques);
let mut mot = json!({"id": uid, "coupes": coupes});
// Les autres découpes justes (ADR 0084). Au-delà de ce que l'API accepte, le joueur ne pourrait
// pas deviner lesquelles poser : on n'en publie aucune plutôt qu'une partie.
let decoupes = self.decoupes(uid, &[]);
if (2..=DECOUPES_MAX).contains(&decoupes.len()) {
mot["autres_coupes"] = json!(&decoupes[1..]);
}
if !assemblage.is_empty() {
mot["assemblage"] = json!(assemblage);
}
Expand Down
49 changes: 24 additions & 25 deletions outils/src/tests/compilation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ fn manifeste_porte_les_donnees_de_jugement_des_ricochets() {
}
}

// ------------------------------------------------------- découpes en chaîne (ADR 0084)
// ------------------------------------------------------- coupes en chaîne (ADR 0084)

/// Le mot publié dans les ricochets du manifeste, par sa graphie.
fn mot_de_ricochet(manifeste: &Value, depot: &Depot, graphie: &str) -> Option<Value> {
Expand All @@ -637,47 +637,46 @@ fn mot_de_ricochet(manifeste: &Value, depot: &Depot, graphie: &str) -> Option<Va
.cloned()
}

#[test]
fn une_composition_chainee_publie_ses_autres_decoupes() {
let depot = Depot::new();
fn manifeste_du_depot(depot: &Depot) -> (Value, TempDir) {
let sortie = TempDir::new().unwrap();
let parutions = compiler_vers(&depot, sortie.path(), true);
let parutions = compiler_vers(depot, sortie.path(), true);
let manifeste = manifeste(sortie.path(), &liste(&parutions["fascicules"])[0]);
// biologiste = biologie · iste, et biologie = bio · logie.
(manifeste, sortie)
}

#[test]
fn une_composition_chainee_publie_toutes_ses_coupes() {
let depot = Depot::new();
let (manifeste, _sortie) = manifeste_du_depot(&depot);
// biologiste = biologie · iste, et biologie = bio · logie : le joueur pose les deux coupes d'un coup.
let mot = mot_de_ricochet(&manifeste, &depot, "biologiste").expect("biologiste est publié");
assert_eq!(mot["coupes"], json!([6]));
assert_eq!(mot["autres_coupes"], json!([[3, 6]]));
assert_eq!(mot["coupes"], json!([3, 6]));
assert!(
mot.get("autres_coupes").is_none(),
"une seule coupe est juste"
);
}

#[test]
fn un_mot_sans_composition_chainee_n_a_pas_d_autres_decoupes() {
fn un_mot_sans_composition_chainee_garde_sa_coupe() {
let depot = Depot::new();
let sortie = TempDir::new().unwrap();
let parutions = compiler_vers(&depot, sortie.path(), true);
let manifeste = manifeste(sortie.path(), &liste(&parutions["fascicules"])[0]);
let (manifeste, _sortie) = manifeste_du_depot(&depot);
let mot = mot_de_ricochet(&manifeste, &depot, "géologie").expect("géologie est publié");
assert_eq!(mot["coupes"], json!([3]));
assert!(mot.get("autres_coupes").is_none());
}

#[test]
fn une_coupe_au_dela_de_la_partie_elidee_n_existe_pas() {
// Si la partie « biologie » ne couvre plus que [0, 2) de « biologiste », la coupe de « biologie »
// à 3 tombe hors du mot : il ne reste que la découpe de premier niveau.
// à 3 tombe hors du mot : il ne reste que la frontière de premier niveau.
let depot = Depot::new();
let ecrire = |depot: &Depot| {
depot.modifier(depot.composition("biologiste"), |c| {
c["parties"][0]["segment_fin"] = json!(2);
c["parties"][1]["segment_debut"] = json!(2);
});
};
ecrire(&depot);
let sortie = TempDir::new().unwrap();
let parutions = compiler_vers(&depot, sortie.path(), true);
let manifeste = manifeste(sortie.path(), &liste(&parutions["fascicules"])[0]);
depot.modifier(depot.composition("biologiste"), |c| {
c["parties"][0]["segment_fin"] = json!(2);
c["parties"][1]["segment_debut"] = json!(2);
});
let (manifeste, _sortie) = manifeste_du_depot(&depot);
let mot = mot_de_ricochet(&manifeste, &depot, "biologiste").expect("biologiste est publié");
assert_eq!(mot["coupes"], json!([2]));
assert!(mot.get("autres_coupes").is_none());
}

// ------------------------------------------------------------------ trompe-l'œil (ADR 0052)
Expand Down
Loading