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
89 changes: 89 additions & 0 deletions contrat/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
94 changes: 72 additions & 22 deletions crates/api/src/progression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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?,
})
}

Expand Down Expand Up @@ -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<Vec<ProgressionCollection>, 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,
Expand Down Expand Up @@ -1306,38 +1339,55 @@ 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)
.await?;
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,
Expand All @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions crates/api/tests/deblocage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}");
}
50 changes: 49 additions & 1 deletion crates/api/tests/quetes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
7 changes: 7 additions & 0 deletions crates/catalogue/src/essai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }
Expand Down
5 changes: 4 additions & 1 deletion crates/catalogue/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Collection>,
}

/// Données de jugement des ricochets d'un fascicule : des identifiants, jamais de texte.
Expand Down
Loading
Loading