mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 02:11:58 +02:00
Add mission scoring, rewards collection and save schema v2
Phase "score et récompenses" of the post-0.1.0 plan (user feedback: wants a more game-like experience with scoring and rewards). - MissionScoring: 1-3 stars from how each exercise was solved (never speed; a finished mission always gives at least one star). - Save schema v2: mission_results (best/last stars, completions) with a v1->v2 migration that keeps existing stars and items. - Total only grows by the improvement over a mission's best score. - Data-driven collection (data/rewards, reward_item schema, validated incl. mission cross-check) unlocked by RewardRules. - Reward screen, collection screen, stars on the world map. - Version 0.2.0. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ extends Node
|
||||
var _rules: Dictionary = {}
|
||||
var _exercises_by_rule_and_type: Dictionary = {}
|
||||
var _dictations_by_rule: Dictionary = {}
|
||||
var _reward_items: Array[Dictionary] = []
|
||||
var _missions: Dictionary = {}
|
||||
var _mission_order: Array[String] = []
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
@@ -21,6 +22,7 @@ func _ready() -> void:
|
||||
_load_exercises()
|
||||
_load_dictations()
|
||||
_load_missions()
|
||||
_load_rewards()
|
||||
|
||||
|
||||
func get_rule(rule_id: String) -> Dictionary:
|
||||
@@ -44,6 +46,12 @@ func get_missions_for_level(level: String) -> Array[Dictionary]:
|
||||
return missions
|
||||
|
||||
|
||||
## Catalogue des objets de la collection, du plus facile au plus difficile
|
||||
## a obtenir (ordre stable : seuil puis identifiant).
|
||||
func get_reward_items() -> Array[Dictionary]:
|
||||
return _reward_items
|
||||
|
||||
|
||||
func get_mission(mission_id: String) -> Dictionary:
|
||||
return _missions.get(mission_id, {})
|
||||
|
||||
@@ -141,3 +149,21 @@ func _load_missions() -> void:
|
||||
mission_ids.append(mission["mission_id"])
|
||||
mission_ids.sort()
|
||||
_mission_order = mission_ids
|
||||
|
||||
|
||||
func _load_rewards() -> void:
|
||||
for path in DataLoader.find_json_files("res://data/rewards"):
|
||||
var errors := DataLoader.validate_file(path, "reward_item")
|
||||
if not errors.is_empty():
|
||||
push_error("Objet de collection ignore (%s) : %s" % [path, errors])
|
||||
continue
|
||||
var item: Dictionary = DataLoader.read_json(path)
|
||||
_reward_items.append(item)
|
||||
_reward_items.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var count_a: int = a["unlock"]["count"]
|
||||
var count_b: int = b["unlock"]["count"]
|
||||
if count_a != count_b:
|
||||
return count_a < count_b
|
||||
return String(a["item_id"]) < String(b["item_id"])
|
||||
)
|
||||
|
||||
@@ -11,11 +11,16 @@ const SCENE_WORLD_MAP := "res://scenes/world/world_map.tscn"
|
||||
const SCENE_MISSION_INTRO := "res://scenes/world/mission_intro.tscn"
|
||||
const SCENE_EXERCISE_HOST := "res://scenes/exercises/exercise_host.tscn"
|
||||
const SCENE_REWARD := "res://scenes/ui/reward_screen.tscn"
|
||||
const SCENE_COLLECTION := "res://scenes/ui/collection.tscn"
|
||||
|
||||
## Mission choisie sur la carte, lue par mission_intro puis exercise_host et
|
||||
## reward_screen. Remise a vide par reward_screen au retour sur la carte.
|
||||
var pending_mission_id: String = ""
|
||||
|
||||
## Resultat de chaque exercice de la mission qui vient de se terminer
|
||||
## (MissionScoring.OUTCOME_*), transmis par exercise_host a reward_screen.
|
||||
var last_outcomes: Array[String] = []
|
||||
|
||||
|
||||
func goto(scene_path: String) -> void:
|
||||
# Differe le changement de scene : l'appeler depuis le _ready() de la
|
||||
|
||||
@@ -37,8 +37,35 @@ func unlock_region(region_id: String) -> void:
|
||||
_current_save = ProgressUpdates.unlock_region(_current_save, region_id)
|
||||
|
||||
|
||||
func add_stars(amount: int) -> void:
|
||||
_current_save = ProgressUpdates.add_stars(_current_save, amount)
|
||||
## Enregistre la fin d'une mission : score en etoiles (MissionScoring),
|
||||
## meilleur score par mission, total, puis objets de la collection
|
||||
## debloques (RewardRules). Renvoie de quoi afficher l'ecran de recompense.
|
||||
## L'ecriture sur disque reste a la charge de save_now().
|
||||
func complete_mission(mission_id: String, outcomes: Array[String]) -> Dictionary:
|
||||
var earned := MissionScoring.stars_for(outcomes)
|
||||
var previous_best := mission_best_stars(mission_id)
|
||||
_current_save = ProgressUpdates.record_mission_result(_current_save, mission_id, earned)
|
||||
|
||||
var new_items := RewardRules.newly_unlocked(
|
||||
ExerciseEngine.get_reward_items(), stars(), _current_save["mission_results"], owned_items()
|
||||
)
|
||||
_current_save = ProgressUpdates.grant_items(_current_save, new_items)
|
||||
return {
|
||||
"stars": earned,
|
||||
"previous_best": previous_best,
|
||||
"is_record": previous_best > 0 and earned > previous_best,
|
||||
"total_stars": stars(),
|
||||
"new_items": new_items,
|
||||
}
|
||||
|
||||
|
||||
func mission_best_stars(mission_id: String) -> int:
|
||||
var results: Dictionary = _current_save.get("mission_results", {})
|
||||
return int(results.get(mission_id, {}).get("best_stars", 0))
|
||||
|
||||
|
||||
func owned_items() -> Array:
|
||||
return _current_save.get("rewards", {}).get("items", [])
|
||||
|
||||
|
||||
func stars() -> int:
|
||||
|
||||
@@ -45,7 +45,24 @@
|
||||
"dictation.input_placeholder": "Écris ici...",
|
||||
"dictation.replay_button": "Réécouter",
|
||||
"dictation.provisional_audio_badge": "Voix provisoire",
|
||||
"reward_screen.title": "Mission réussie !",
|
||||
"reward_screen.title": "Mission terminée !",
|
||||
"reward_screen.stars_label": "Étoiles gagnées :",
|
||||
"reward_screen.message_1": "Chaque essai te fait progresser. Tu peux rejouer pour gagner plus d'étoiles !",
|
||||
"reward_screen.message_2": "Bien joué ! Tu as trouvé toutes les réponses.",
|
||||
"reward_screen.message_3": "Parfait ! Tu as tout réussi du premier coup.",
|
||||
"reward_screen.record_label": "Nouveau record pour cette mission !",
|
||||
"reward_screen.total_label": "Total d'étoiles :",
|
||||
"reward_screen.new_items_label": "Nouveaux objets dans ta collection :",
|
||||
"reward_screen.collection_button": "Voir ma collection",
|
||||
"collection.title": "Ma collection",
|
||||
"collection.progress_label": "Objets trouvés :",
|
||||
"collection.locked_title": "Objet mystère",
|
||||
"collection.unlock_total_stars": "À débloquer avec %d étoiles au total",
|
||||
"collection.unlock_mission_stars": "À débloquer avec %d étoiles à la mission « %s »",
|
||||
"collection.back_button": "Retour à la carte",
|
||||
"collection.owned_marker": "Trouvé",
|
||||
"world_map.total_stars_label": "Total d'étoiles :",
|
||||
"world_map.mission_stars_label": "Étoiles :",
|
||||
"world_map.collection_button": "Ma collection",
|
||||
"reward_screen.continue_button": "Retour à la carte"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"item_id": "item_carte_ancienne",
|
||||
"title": "Carte ancienne",
|
||||
"description": "Une vieille carte qui montre des lieux que personne n'a encore visités.",
|
||||
"unlock": {
|
||||
"type": "total_stars",
|
||||
"count": 15
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"item_id": "item_cle_argentee",
|
||||
"title": "Clé argentée",
|
||||
"description": "Elle ouvre la porte d'un secret caché au fond de la forêt.",
|
||||
"unlock": {
|
||||
"type": "total_stars",
|
||||
"count": 30
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"item_id": "item_couronne_feuilles",
|
||||
"title": "Couronne de feuilles",
|
||||
"description": "Tressée avec les feuilles de la forêt des accords.",
|
||||
"unlock": {
|
||||
"type": "total_stars",
|
||||
"count": 22
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"item_id": "item_cristal_echo",
|
||||
"title": "Cristal d'écho",
|
||||
"description": "Il répète en chantant toutes les phrases bien écrites.",
|
||||
"unlock": {
|
||||
"type": "total_stars",
|
||||
"count": 45
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"item_id": "item_graine_magique",
|
||||
"title": "Graine magique",
|
||||
"description": "Une petite graine qui brille. Elle attend la bonne forêt pour pousser.",
|
||||
"unlock": {
|
||||
"type": "total_stars",
|
||||
"count": 3
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"item_id": "item_lanterne_foret",
|
||||
"title": "Lanterne de la forêt",
|
||||
"description": "Elle éclaire les chemins quand la nuit tombe sur les mots.",
|
||||
"unlock": {
|
||||
"type": "total_stars",
|
||||
"count": 10
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"item_id": "item_plume_doree",
|
||||
"title": "Plume dorée",
|
||||
"description": "Une plume tombée de l'aile de Plume, le hibou guide.",
|
||||
"unlock": {
|
||||
"type": "total_stars",
|
||||
"count": 6
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"item_id": "item_trophee_clairiere",
|
||||
"title": "Trophée de la clairière",
|
||||
"description": "L'écho de la clairière chante ton nom : tu as tout réussi sans aide.",
|
||||
"unlock": {
|
||||
"type": "mission_stars",
|
||||
"count": 3,
|
||||
"mission_id": "mission_03_clairiere_echo"
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"item_id": "item_trophee_pont",
|
||||
"title": "Trophée du pont",
|
||||
"description": "Le pont des phrases brille grâce à toi : tu as tout réussi sans aide.",
|
||||
"unlock": {
|
||||
"type": "mission_stars",
|
||||
"count": 3,
|
||||
"mission_id": "mission_02_pont_des_phrases"
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"item_id": "item_trophee_pre",
|
||||
"title": "Trophée du pré",
|
||||
"description": "Les lutins t'offrent ce trophée : tu as tout réussi sans aide au pré des petits mots.",
|
||||
"unlock": {
|
||||
"type": "mission_stars",
|
||||
"count": 3,
|
||||
"mission_id": "mission_01_pre_des_mots"
|
||||
},
|
||||
"review_status": "draft"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["item_id", "title", "description", "unlock", "review_status"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"item_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string", "minLength": 1 },
|
||||
"unlock": {
|
||||
"type": "object",
|
||||
"required": ["type", "count"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["total_stars", "mission_stars"] },
|
||||
"count": { "type": "integer", "minimum": 1 },
|
||||
"mission_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }
|
||||
}
|
||||
},
|
||||
"review_status": { "type": "string", "enum": ["draft", "validated"] }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"session_count",
|
||||
"unlocked_regions",
|
||||
"skills",
|
||||
"mission_results",
|
||||
"rewards"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
@@ -48,6 +49,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mission_results": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": ["best_stars", "last_stars", "completions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"best_stars": { "type": "integer", "minimum": 1, "maximum": 3 },
|
||||
"last_stars": { "type": "integer", "minimum": 1, "maximum": 3 },
|
||||
"completions": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"rewards": {
|
||||
"type": "object",
|
||||
"required": ["stars", "items"],
|
||||
|
||||
@@ -270,3 +270,21 @@ Ajouts à l'arborescence proposée :
|
||||
**Mise en œuvre** : `config/version="0.1.0"` dans `project.godot` ; PCK embarqué dans l'exécutable (`binary_format/embed_pck=true`) pour un fichier unique par plateforme ; `make package` (`tools/package.sh`) réexporte depuis zéro puis crée `dist/` : `.tar.gz` Linux et `.zip` Windows contenant l'exécutable, `LICENSE`, `README.md`, `README-EN.md`, `CREDITS.md` et une notice `LISEZMOI.txt` (lancement, emplacement des sauvegardes, voix provisoires), plus `SHA256SUMS`. Le script refuse d'empaqueter un document vide (un README tronqué par une erreur d'édition a été détecté pendant cette production).
|
||||
|
||||
**Écarts connus par rapport au cahier des charges, non traités** (hors périmètre du §15 ou nécessitant des ressources/décisions humaines) : voix préenregistrées naturelles (§16 : voix de synthèse provisoires), illustrations originales et personnage guide animé (formes simples, §11/§16), espace adulte et export/restauration des sauvegardes (§9/§10), écran de réglages (volume, taille du texte, animations — `SettingsManager` existe sans interface), tests sur appareils réels et sur d'autres distributions/Windows (§12/§13), contenu entièrement en `draft` (validation humaine requise avant diffusion).
|
||||
|
||||
## 30. Phase « Score et récompenses » (version 0.2.0)
|
||||
|
||||
**Origine** : retour utilisateur après la version 0.1.0 — « un jeu attrayant, moderne, avec notation, récompenses, sauvegardes ». Quatre chantiers retenus (habillage, récompenses et collection, réglages et espace adulte, son) ; ordre validé : la logique testable d'abord (cette phase), l'habillage ensuite. « Notation » = score de jeu, pas une note scolaire (§9) ni liée à la vitesse (§2) ; confirmé par l'utilisateur.
|
||||
|
||||
**Score d'une mission** (`MissionScoring`, code pur) : 3 étoiles si tous les exercices sont réussis du premier coup ; 2 si tout est trouvé mais avec au moins un indice ou une explication ; 1 si une réponse a dû être montrée. Une mission terminée rapporte toujours au moins 1 étoile (une erreur n'est jamais punie). `exercise_host` note l'issue de chaque exercice (`first_try`, `with_help`, `revealed`) et la transmet à l'écran de récompense via `GameManager.last_outcomes`.
|
||||
|
||||
**Format de sauvegarde — `schema_version` 2** (décision engageant le format, signalée avant implémentation) : nouveau champ obligatoire `mission_results` (mission_id → `best_stars`, `last_stars`, `completions`). `SaveMigration` monte les sauvegardes v1 en v2 en ajoutant `mission_results: {}` ; `rewards.stars` et `rewards.items` existants sont conservés tels quels. Le détail par mission n'existait pas en v1 : une mission déjà rejouée peut donc ajouter ses étoiles une seconde fois (au plus +1 étoile par mission déjà terminée sous v1, accepté plutôt que d'inventer un historique).
|
||||
|
||||
**Total d'étoiles** : on n'ajoute que l'amélioration par rapport au meilleur score de la mission (1 puis 3 étoiles = 3, pas 4) ; rejouer pour progresser est encouragé sans gonfler le total. L'ancienne `ProgressUpdates.add_stars` et la récompense fixe d'une étoile sont supprimées.
|
||||
|
||||
**Collection** : nouveau type de donnée `data/rewards/*.json` (schéma `reward_item`, validé au chargement et par `make validate-data`, avec contrôle que `mission_id` désigne une mission existante). 10 objets (`draft`) : 7 à seuil d'étoiles totales (3 à 45) et 3 trophées de mission (3 étoiles sur chacune des missions CE1 d'origine). `RewardRules` (pur) calcule les objets nouvellement débloqués ; un objet non débloqué reste « mystère » mais indique toujours comment l'obtenir. Objets sans illustration pour l'instant (texte seul) : l'habillage est la phase suivante.
|
||||
|
||||
**Interface** : écran de récompense (étoiles x/3, message sans jugement, « nouveau record », total, nouveaux objets, accès à la collection), écran « Ma collection », total d'étoiles et étoiles par mission sur la carte, marqueurs agrandis (48 px de demi-hauteur) pour trois lignes de texte avec retour à la ligne.
|
||||
|
||||
**Tests** : scoring, règles de déblocage, mise à jour de sauvegarde, migration v1→v2, validation du schéma, suivi des issues dans `exercise_host`, écran de récompense et collection de bout en bout. **Non vérifié** (sans écran) : rendu et lisibilité des trois lignes des marqueurs, mise en page de l'écran de récompense et de la collection, défilement de la liste.
|
||||
|
||||
**À faire (phases suivantes)** : habillage visuel et illustrations des objets, réglages et espace adulte (dont export/restauration des sauvegardes), son et musique.
|
||||
|
||||
@@ -17,6 +17,7 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a
|
||||
11. Mission « La clairière de l'écho » (dictée) : à l'arrivée sur l'exercice, la voix de synthèse doit se faire entendre automatiquement ; le bouton « Réécouter » la rejoue sans planter ; le badge « Voix provisoire » est visible ; la zone de saisie accepte le texte et les accents. Le rappel « N'oublie pas : une majuscule au début de la phrase et un point à la fin ! » doit être visible (point 22 de `decisions.md`) ; taper le texte sans majuscule ou sans point final doit être refusé, avec l'erreur correspondante.
|
||||
12. Après 3 exercices, écran de récompense avec le nombre d'étoiles, puis retour à la carte.
|
||||
12ter. Profil **CE2** : la carte affiche « Le marais des homophones » avec 12 lieux (4 colonnes × 3 lignes, sans chevauchement) et un bouton « Revoir le CE1 » qui bascule sur les 9 lieux CE1 (et « Retour au CE2 » revient) ; un profil CE1 n'a pas ce bouton (point 27 de `decisions.md`). Jamais vu à l'écran. Jouer une mission de chaque règle CE2 (a/à, et/est, son/sont, on/ont), dont une dictée : voix claire, rappel majuscule/point visible.
|
||||
12quater. Score et récompenses (point 30 de `decisions.md`) : terminer une mission sans aucune erreur donne 3 étoiles, avec un indice 2, avec une réponse montrée 1 (message encourageant, jamais de blâme) ; la carte affiche « Total d'étoiles » et « Étoiles : n/3 » sur chaque lieu sans texte coupé ; rejouer avec un meilleur score affiche « Nouveau record » et n'ajoute que la différence ; « Ma collection » montre les objets trouvés et les objets mystère avec la façon de les obtenir (liste défilante). Quitter puis relancer : étoiles et objets conservés.
|
||||
12bis. Répéter rapidement les points 8-12 sur une mission de chacune des deux nouvelles règles (« La ruche qui se multiplie » pour le pluriel du nom, « Le sentier des bons compagnons » pour l'accord du déterminant) : jamais vu à l'écran, seulement validé en headless (point 25 de `decisions.md`).
|
||||
13. Quitter le jeu puis relancer `make run` : le profil et sa progression doivent être conservés (sélectionner le même profil, vérifier que l'état affiché sur la carte a changé par rapport à l'état initial).
|
||||
14. Depuis la sélection de profil, supprimer un profil : une confirmation explicite doit apparaître avant la suppression effective ; annuler doit ne rien supprimer.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="Les Aventuriers des mots"
|
||||
config/version="0.1.0"
|
||||
config/version="0.2.0"
|
||||
run/main_scene="res://scenes/boot/boot.tscn"
|
||||
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
@@ -23,6 +23,8 @@ var _current_difficulty := 1
|
||||
var _attempt_number := 1
|
||||
var _completed_count := 0
|
||||
var _last_exercise_id := ""
|
||||
var _outcomes: Array[String] = []
|
||||
var _pending_outcome := ""
|
||||
|
||||
@onready var _exercise_container: Control = %ExerciseContainer
|
||||
@onready var _overlay_container: Control = %OverlayContainer
|
||||
@@ -43,6 +45,10 @@ func get_completed_count() -> int:
|
||||
return _completed_count
|
||||
|
||||
|
||||
func get_outcomes() -> Array[String]:
|
||||
return _outcomes
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_mission = ExerciseEngine.get_mission(GameManager.pending_mission_id)
|
||||
_rule = ExerciseEngine.get_rule(_mission["rule_id"])
|
||||
@@ -126,6 +132,15 @@ func _handle_result(result: CorrectionResult) -> void:
|
||||
support_level = AttemptFlow.support_level_for_attempt(_attempt_number)
|
||||
is_terminal = AttemptFlow.should_end_exercise(_attempt_number)
|
||||
|
||||
if result.is_correct:
|
||||
_pending_outcome = (
|
||||
MissionScoring.OUTCOME_FIRST_TRY
|
||||
if _attempt_number == 1
|
||||
else MissionScoring.OUTCOME_WITH_HELP
|
||||
)
|
||||
elif is_terminal:
|
||||
_pending_outcome = MissionScoring.OUTCOME_REVEALED
|
||||
|
||||
ProgressManager.record_attempt(
|
||||
_skill_id, result.is_correct, _attempt_number > 1, _mission["exercise_type"]
|
||||
)
|
||||
@@ -154,8 +169,10 @@ func _on_feedback_continued(feedback: Control, is_terminal: bool) -> void:
|
||||
)
|
||||
_last_exercise_id = _current_exercise.get("id", "")
|
||||
_completed_count += 1
|
||||
_outcomes.append(_pending_outcome)
|
||||
|
||||
if _completed_count >= MISSION_LENGTH:
|
||||
GameManager.last_outcomes = _outcomes.duplicate()
|
||||
GameManager.goto(GameManager.SCENE_REWARD)
|
||||
else:
|
||||
_load_next_exercise()
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
extends Control
|
||||
## Collection d'objets gagnes avec les etoiles. Un objet non debloque reste
|
||||
## mysterieux mais indique comment l'obtenir (jamais de mystere frustrant).
|
||||
|
||||
@onready var _title_label: Label = %TitleLabel
|
||||
@onready var _progress_label: Label = %ProgressLabel
|
||||
@onready var _items_box: VBoxContainer = %ItemsBox
|
||||
@onready var _back_button: Button = %BackButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_title_label.text = UIText.get_text("collection.title")
|
||||
_back_button.text = UIText.get_text("collection.back_button")
|
||||
_back_button.pressed.connect(_on_back_pressed)
|
||||
|
||||
var owned: Array = ProgressManager.owned_items()
|
||||
var catalog := ExerciseEngine.get_reward_items()
|
||||
var found := 0
|
||||
for item in catalog:
|
||||
var is_owned := owned.has(item["item_id"])
|
||||
if is_owned:
|
||||
found += 1
|
||||
_items_box.add_child(_build_row(item, is_owned))
|
||||
_progress_label.text = (
|
||||
"%s %d/%d" % [UIText.get_text("collection.progress_label"), found, catalog.size()]
|
||||
)
|
||||
|
||||
|
||||
func _build_row(item: Dictionary, is_owned: bool) -> Control:
|
||||
var label := Label.new()
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.custom_minimum_size = Vector2(560, 0)
|
||||
if is_owned:
|
||||
label.text = (
|
||||
"%s — %s\n%s"
|
||||
% [item["title"], UIText.get_text("collection.owned_marker"), item["description"]]
|
||||
)
|
||||
else:
|
||||
label.text = (
|
||||
"%s\n%s" % [UIText.get_text("collection.locked_title"), _unlock_hint(item["unlock"])]
|
||||
)
|
||||
return label
|
||||
|
||||
|
||||
func _unlock_hint(unlock: Dictionary) -> String:
|
||||
var count: int = int(unlock["count"])
|
||||
if unlock["type"] == "mission_stars":
|
||||
var mission := ExerciseEngine.get_mission(unlock.get("mission_id", ""))
|
||||
return UIText.get_text("collection.unlock_mission_stars") % [count, mission.get("title", "")]
|
||||
return UIText.get_text("collection.unlock_total_stars") % count
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
GameManager.goto(GameManager.SCENE_WORLD_MAP)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1bk5xhhar5nj
|
||||
@@ -0,0 +1,54 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/collection.gd" id="1"]
|
||||
|
||||
[node name="Collection" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="Margin/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 32
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ProgressLabel" type="Label" parent="Margin/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="Margin/VBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="ItemsBox" type="VBoxContainer" parent="Margin/VBox/Scroll"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="Margin/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
+42
-10
@@ -1,28 +1,60 @@
|
||||
extends Control
|
||||
## Ecran de recompense : une mission terminee rapporte toujours la meme
|
||||
## recompense, que l'enfant ait eu besoin d'aide ou non (cahier des charges
|
||||
## §2, principe 6 : pas de recompense liee a la vitesse ou a la performance).
|
||||
## Sauvegarde groupee ici, en fin de mission (docs/specs/decisions.md point 7).
|
||||
|
||||
const STARS_PER_MISSION := 1
|
||||
## Ecran de fin de mission : etoiles gagnees (1 a 3, selon la reussite et
|
||||
## l'aide utilisee, jamais selon la vitesse : cahier des charges §2), total,
|
||||
## nouveaux objets de la collection. Une mission terminee rapporte toujours
|
||||
## au moins une etoile. Sauvegarde groupee ici, en fin de mission
|
||||
## (docs/specs/decisions.md point 7).
|
||||
|
||||
@onready var _title_label: Label = %TitleLabel
|
||||
@onready var _stars_label: Label = %StarsLabel
|
||||
@onready var _message_label: Label = %MessageLabel
|
||||
@onready var _record_label: Label = %RecordLabel
|
||||
@onready var _total_label: Label = %TotalLabel
|
||||
@onready var _new_items_label: Label = %NewItemsLabel
|
||||
@onready var _collection_button: Button = %CollectionButton
|
||||
@onready var _continue_button: Button = %ContinueButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
ProgressManager.add_stars(STARS_PER_MISSION)
|
||||
var summary := ProgressManager.complete_mission(
|
||||
GameManager.pending_mission_id, GameManager.last_outcomes
|
||||
)
|
||||
ProgressManager.save_now()
|
||||
|
||||
_title_label.text = UIText.get_text("reward_screen.title")
|
||||
_stars_label.text = "%s %d" % [
|
||||
UIText.get_text("reward_screen.stars_label"), ProgressManager.stars()
|
||||
]
|
||||
_stars_label.text = (
|
||||
"%s %d/%d"
|
||||
% [UIText.get_text("reward_screen.stars_label"), summary["stars"], MissionScoring.MAX_STARS]
|
||||
)
|
||||
_message_label.text = UIText.get_text("reward_screen.message_%d" % summary["stars"])
|
||||
_record_label.text = UIText.get_text("reward_screen.record_label")
|
||||
_record_label.visible = summary["is_record"]
|
||||
_total_label.text = (
|
||||
"%s %d" % [UIText.get_text("reward_screen.total_label"), summary["total_stars"]]
|
||||
)
|
||||
|
||||
var new_items: Array = summary["new_items"]
|
||||
_new_items_label.visible = not new_items.is_empty()
|
||||
if not new_items.is_empty():
|
||||
var titles: Array[String] = []
|
||||
for item in ExerciseEngine.get_reward_items():
|
||||
if new_items.has(item["item_id"]):
|
||||
titles.append(item["title"])
|
||||
_new_items_label.text = (
|
||||
"%s\n%s" % [UIText.get_text("reward_screen.new_items_label"), "\n".join(titles)]
|
||||
)
|
||||
|
||||
_collection_button.text = UIText.get_text("reward_screen.collection_button")
|
||||
_collection_button.pressed.connect(_on_collection_pressed)
|
||||
_continue_button.text = UIText.get_text("reward_screen.continue_button")
|
||||
_continue_button.pressed.connect(_on_continue_pressed)
|
||||
|
||||
|
||||
func _on_collection_pressed() -> void:
|
||||
GameManager.pending_mission_id = ""
|
||||
GameManager.goto(GameManager.SCENE_COLLECTION)
|
||||
|
||||
|
||||
func _on_continue_pressed() -> void:
|
||||
GameManager.pending_mission_id = ""
|
||||
GameManager.goto(GameManager.SCENE_WORLD_MAP)
|
||||
|
||||
@@ -34,6 +34,35 @@ unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="MessageLabel" type="Label" parent="CenterContainer/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
custom_minimum_size = Vector2(480, 0)
|
||||
|
||||
[node name="RecordLabel" type="Label" parent="CenterContainer/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TotalLabel" type="Label" parent="CenterContainer/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="NewItemsLabel" type="Label" parent="CenterContainer/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
custom_minimum_size = Vector2(480, 0)
|
||||
|
||||
[node name="CollectionButton" type="Button" parent="CenterContainer/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(200, 56)
|
||||
|
||||
[node name="ContinueButton" type="Button" parent="CenterContainer/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
@@ -7,19 +7,23 @@ extends Control
|
||||
## peut basculer sur la carte CE1 pour revoir les notions precedentes.
|
||||
|
||||
const MARKER_HALF_WIDTH := 80.0
|
||||
const MARKER_HALF_HEIGHT := 36.0
|
||||
const MARKER_HALF_HEIGHT := 48.0
|
||||
|
||||
var _viewed_level := "CE1"
|
||||
|
||||
@onready var _title_label: Label = %TitleLabel
|
||||
@onready var _map_area: Control = %MapArea
|
||||
@onready var _level_toggle_button: Button = %LevelToggleButton
|
||||
@onready var _total_stars_label: Label = %TotalStarsLabel
|
||||
@onready var _collection_button: Button = %CollectionButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_viewed_level = ProgressManager.school_level()
|
||||
_level_toggle_button.visible = _viewed_level == "CE2"
|
||||
_level_toggle_button.pressed.connect(_on_level_toggle_pressed)
|
||||
_collection_button.text = UIText.get_text("world_map.collection_button")
|
||||
_collection_button.pressed.connect(_on_collection_pressed)
|
||||
_refresh()
|
||||
|
||||
|
||||
@@ -30,6 +34,9 @@ func _refresh() -> void:
|
||||
"world_map.review_ce1_button" if _viewed_level == "CE2" else "world_map.back_to_ce2_button"
|
||||
)
|
||||
_level_toggle_button.text = UIText.get_text(toggle_key)
|
||||
_total_stars_label.text = (
|
||||
"%s %d" % [UIText.get_text("world_map.total_stars_label"), ProgressManager.stars()]
|
||||
)
|
||||
_populate_missions()
|
||||
|
||||
|
||||
@@ -42,6 +49,10 @@ func _populate_missions() -> void:
|
||||
_map_area.add_child(_build_marker(mission))
|
||||
|
||||
|
||||
func _on_collection_pressed() -> void:
|
||||
GameManager.goto(GameManager.SCENE_COLLECTION)
|
||||
|
||||
|
||||
func _on_level_toggle_pressed() -> void:
|
||||
_viewed_level = "CE1" if _viewed_level == "CE2" else "CE2"
|
||||
_refresh()
|
||||
@@ -49,7 +60,17 @@ func _on_level_toggle_pressed() -> void:
|
||||
|
||||
func _build_marker(mission: Dictionary) -> Control:
|
||||
var button := Button.new()
|
||||
button.text = "%s\n%s" % [mission["title"], _status_label_for(mission)]
|
||||
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
button.text = (
|
||||
"%s\n%s\n%s %d/%d"
|
||||
% [
|
||||
mission["title"],
|
||||
_status_label_for(mission),
|
||||
UIText.get_text("world_map.mission_stars_label"),
|
||||
ProgressManager.mission_best_stars(mission["mission_id"]),
|
||||
MissionScoring.MAX_STARS,
|
||||
]
|
||||
)
|
||||
|
||||
var map_position: Dictionary = mission["map_position"]
|
||||
var x: float = map_position["x"]
|
||||
|
||||
@@ -36,6 +36,11 @@ layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 32
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TotalStarsLabel" type="Label" parent="VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="MapArea" type="Control" parent="VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
@@ -46,6 +51,11 @@ unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
|
||||
[node name="CollectionButton" type="Button" parent="VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
|
||||
[node name="Guide" type="Control" parent="VBox" instance=ExtResource("2")]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
@@ -50,3 +50,21 @@ static func check_dictation(data: Dictionary, path: String) -> Array[String]:
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
static func check_reward_item(data: Dictionary, path: String) -> Array[String]:
|
||||
var errors: Array[String] = []
|
||||
var unlock: Dictionary = data.get("unlock", {})
|
||||
var count: int = int(unlock.get("count", 0))
|
||||
match unlock.get("type", ""):
|
||||
"total_stars":
|
||||
if unlock.has("mission_id"):
|
||||
errors.append("%s : total_stars ne prend pas de mission_id" % path)
|
||||
"mission_stars":
|
||||
if not unlock.has("mission_id"):
|
||||
errors.append("%s : mission_stars exige un mission_id" % path)
|
||||
if count > 3:
|
||||
errors.append(
|
||||
"%s : une mission rapporte au plus 3 etoiles (count=%d)" % [path, count]
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -13,6 +13,7 @@ func _init() -> void:
|
||||
checked += _validate_dictations("res://data/dictations", failures)
|
||||
checked += _validate_exercises("res://data/exercises", failures)
|
||||
checked += _validate_missions("res://data/missions", failures)
|
||||
checked += _validate_rewards("res://data/rewards", failures)
|
||||
checked += _validate_simple_dir("res://data/localization", "localization", failures)
|
||||
|
||||
if failures.is_empty():
|
||||
@@ -92,3 +93,27 @@ func _validate_exercises(dir_path: String, failures: Array[String]) -> int:
|
||||
"sentence_correction":
|
||||
failures.append_array(SemanticChecks.check_sentence_correction(data, path))
|
||||
return count
|
||||
|
||||
|
||||
func _validate_rewards(dir_path: String, failures: Array[String]) -> int:
|
||||
var known_mission_ids: Array[String] = []
|
||||
for path in DataLoader.find_json_files("res://data/missions"):
|
||||
var mission: Variant = DataLoader.read_json(path)
|
||||
if typeof(mission) == TYPE_DICTIONARY and mission.has("mission_id"):
|
||||
known_mission_ids.append(mission["mission_id"])
|
||||
|
||||
var count := 0
|
||||
for path in DataLoader.find_json_files(dir_path):
|
||||
count += 1
|
||||
var schema_errors := DataLoader.validate_file(path, "reward_item")
|
||||
failures.append_array(schema_errors)
|
||||
if not schema_errors.is_empty():
|
||||
continue
|
||||
var data: Dictionary = DataLoader.read_json(path)
|
||||
failures.append_array(SemanticChecks.check_reward_item(data, path))
|
||||
var mission_id: String = data["unlock"].get("mission_id", "")
|
||||
if not mission_id.is_empty() and not known_mission_ids.has(mission_id):
|
||||
failures.append(
|
||||
"%s : mission_id '%s' ne correspond a aucune mission" % [path, mission_id]
|
||||
)
|
||||
return count
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
class_name MissionScoring
|
||||
extends RefCounted
|
||||
## Score d'une mission en etoiles (1 a 3), calcule a partir de ce qui s'est
|
||||
## passe sur chaque exercice. Code pur. Le score valorise la reussite sans
|
||||
## aide, jamais la vitesse ni la duree (cahier des charges §2) ; ce n'est
|
||||
## pas une note scolaire (§9). Une mission terminee rapporte toujours au
|
||||
## moins une etoile : l'erreur n'est jamais punie.
|
||||
|
||||
const OUTCOME_FIRST_TRY := "first_try"
|
||||
const OUTCOME_WITH_HELP := "with_help"
|
||||
const OUTCOME_REVEALED := "revealed"
|
||||
|
||||
const MIN_STARS := 1
|
||||
const MAX_STARS := 3
|
||||
|
||||
|
||||
## 3 etoiles : tous les exercices reussis du premier coup.
|
||||
## 2 etoiles : tous reussis, au moins un apres un indice ou une explication.
|
||||
## 1 etoile : au moins une reponse a ete montree (mission terminee quand meme).
|
||||
static func stars_for(outcomes: Array[String]) -> int:
|
||||
if outcomes.is_empty():
|
||||
return MIN_STARS
|
||||
var all_first_try := true
|
||||
for outcome in outcomes:
|
||||
if outcome == OUTCOME_REVEALED:
|
||||
return 1
|
||||
if outcome != OUTCOME_FIRST_TRY:
|
||||
all_first_try = false
|
||||
return MAX_STARS if all_first_try else 2
|
||||
@@ -0,0 +1 @@
|
||||
uid://53b456qsmdmt
|
||||
@@ -0,0 +1,34 @@
|
||||
class_name RewardRules
|
||||
extends RefCounted
|
||||
## Regles de deblocage des objets de la collection, a partir du catalogue
|
||||
## valide de data/rewards/. Code pur : renvoie ce qui vient d'etre debloque,
|
||||
## ProgressManager se charge de l'enregistrer.
|
||||
|
||||
|
||||
## catalog : liste d'objets conformes a reward_item.schema.json.
|
||||
## best_stars_by_mission : mission_id -> {"best_stars": int, ...}.
|
||||
## owned : item_id deja possedes (jamais redebloques).
|
||||
static func newly_unlocked(
|
||||
catalog: Array[Dictionary], total_stars: int, best_stars_by_mission: Dictionary, owned: Array
|
||||
) -> Array[String]:
|
||||
var unlocked: Array[String] = []
|
||||
for item in catalog:
|
||||
var item_id: String = item["item_id"]
|
||||
if owned.has(item_id) or unlocked.has(item_id):
|
||||
continue
|
||||
if _is_unlocked(item["unlock"], total_stars, best_stars_by_mission):
|
||||
unlocked.append(item_id)
|
||||
return unlocked
|
||||
|
||||
|
||||
static func _is_unlocked(
|
||||
unlock: Dictionary, total_stars: int, best_stars_by_mission: Dictionary
|
||||
) -> bool:
|
||||
var count: int = int(unlock["count"])
|
||||
match unlock["type"]:
|
||||
"total_stars":
|
||||
return total_stars >= count
|
||||
"mission_stars":
|
||||
var result: Dictionary = best_stars_by_mission.get(unlock.get("mission_id", ""), {})
|
||||
return int(result.get("best_stars", 0)) >= count
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://daxy0fqgngwvj
|
||||
@@ -60,12 +60,38 @@ static func unlock_region(save_data: Dictionary, region_id: String) -> Dictionar
|
||||
return updated
|
||||
|
||||
|
||||
## Les recompenses valorisent l'effort et la progression, jamais la vitesse
|
||||
## (cahier des charges §2, principe 6) : un nombre fixe d'etoiles par mission
|
||||
## terminee, quel que soit le temps pris ou le nombre d'aides utilisees.
|
||||
static func add_stars(save_data: Dictionary, amount: int) -> Dictionary:
|
||||
## Enregistre le resultat d'une mission terminee. Le meilleur score est
|
||||
## conserve ; seules les etoiles gagnees en plus du meilleur score precedent
|
||||
## s'ajoutent au total, donc rejouer une mission ne gonfle pas le total.
|
||||
static func record_mission_result(
|
||||
save_data: Dictionary, mission_id: String, stars: int
|
||||
) -> Dictionary:
|
||||
var updated: Dictionary = save_data.duplicate(true)
|
||||
var results: Dictionary = updated["mission_results"]
|
||||
var previous: Dictionary = results.get(mission_id, {})
|
||||
var previous_best: int = int(previous.get("best_stars", 0))
|
||||
var new_best: int = maxi(previous_best, stars)
|
||||
|
||||
results[mission_id] = {
|
||||
"best_stars": new_best,
|
||||
"last_stars": stars,
|
||||
"completions": int(previous.get("completions", 0)) + 1,
|
||||
}
|
||||
updated["mission_results"] = results
|
||||
|
||||
var rewards: Dictionary = updated["rewards"].duplicate(true)
|
||||
rewards["stars"] = int(rewards["stars"]) + amount
|
||||
rewards["stars"] = int(rewards["stars"]) + (new_best - previous_best)
|
||||
updated["rewards"] = rewards
|
||||
return updated
|
||||
|
||||
|
||||
static func grant_items(save_data: Dictionary, item_ids: Array[String]) -> Dictionary:
|
||||
var updated: Dictionary = save_data.duplicate(true)
|
||||
var rewards: Dictionary = updated["rewards"].duplicate(true)
|
||||
var items: Array = rewards["items"]
|
||||
for item_id in item_ids:
|
||||
if not items.has(item_id):
|
||||
items.append(item_id)
|
||||
rewards["items"] = items
|
||||
updated["rewards"] = rewards
|
||||
return updated
|
||||
|
||||
@@ -13,5 +13,6 @@ static func new_save(profile_id: String, display_name: String, school_level: Str
|
||||
"session_count": 0,
|
||||
"unlocked_regions": [],
|
||||
"skills": {},
|
||||
"mission_results": {},
|
||||
"rewards": {"stars": 0, "items": []},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
class_name SaveMigration
|
||||
extends RefCounted
|
||||
## Migration des sauvegardes entre versions de schema. Code pur. Une seule
|
||||
## version existe pour l'instant (1) ; chaque montee de version future ajoute
|
||||
## une etape a la chaine ci-dessous, sans jamais reecrire les etapes passees.
|
||||
## Migration des sauvegardes entre versions de schema. Code pur. Chaque
|
||||
## montee de version ajoute une etape a la chaine ci-dessous, sans jamais
|
||||
## reecrire les etapes passees.
|
||||
## Version 2 : ajout de mission_results (meilleur score par mission).
|
||||
|
||||
const CURRENT_SCHEMA_VERSION := 1
|
||||
const CURRENT_SCHEMA_VERSION := 2
|
||||
|
||||
|
||||
## Retourne un dictionnaire vide si la version est absente, invalide, ou plus
|
||||
@@ -21,10 +22,17 @@ static func migrate(data: Dictionary) -> Dictionary:
|
||||
return {}
|
||||
|
||||
var migrated: Dictionary = data.duplicate(true)
|
||||
# Chaine de montees de version, a completer quand CURRENT_SCHEMA_VERSION
|
||||
# augmentera :
|
||||
# if version_int == 1:
|
||||
# migrated = _migrate_v1_to_v2(migrated)
|
||||
# version_int = 2
|
||||
if version_int == 1:
|
||||
migrated = _migrate_v1_to_v2(migrated)
|
||||
version_int = 2
|
||||
migrated["schema_version"] = CURRENT_SCHEMA_VERSION
|
||||
return migrated
|
||||
|
||||
|
||||
## Les etoiles deja gagnees (rewards.stars) sont conservees telles quelles ;
|
||||
## le detail par mission n'existait pas, il repart vide.
|
||||
static func _migrate_v1_to_v2(data: Dictionary) -> Dictionary:
|
||||
var migrated: Dictionary = data.duplicate(true)
|
||||
if not migrated.has("mission_results"):
|
||||
migrated["mission_results"] = {}
|
||||
return migrated
|
||||
|
||||
@@ -29,6 +29,16 @@ func test_missions_are_filtered_by_the_level_of_their_rule() -> void:
|
||||
assert_eq(ExerciseEngine.get_missions_for_level("CM1").size(), 0)
|
||||
|
||||
|
||||
func test_reward_catalog_is_loaded_and_sorted_by_threshold() -> void:
|
||||
var items := ExerciseEngine.get_reward_items()
|
||||
assert_eq(items.size(), 10)
|
||||
var counts: Array = items.map(func(i: Dictionary) -> int: return i["unlock"]["count"])
|
||||
var sorted_counts := counts.duplicate()
|
||||
sorted_counts.sort()
|
||||
assert_eq(counts, sorted_counts)
|
||||
assert_eq(items[0]["item_id"], "item_graine_magique", "seuil 3, ordre alphabetique")
|
||||
|
||||
|
||||
func test_get_mission_returns_the_requested_mission() -> void:
|
||||
var mission := ExerciseEngine.get_mission("mission_02_pont_des_phrases")
|
||||
assert_eq(mission["exercise_type"], "sentence_correction")
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
extends GutTest
|
||||
## exercise_host note, pour chaque exercice termine, comment il a ete resolu
|
||||
## (du premier coup, avec aide, reponse montree) : c'est ce qui alimente le
|
||||
## score de la mission (MissionScoring). Le changement de scene final n'est
|
||||
## jamais atteint ici.
|
||||
|
||||
var _profile_id: String = ""
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
_profile_id = ProfileManager.create_profile("Test", "CE1")
|
||||
ProfileManager.select_profile(_profile_id)
|
||||
GameManager.pending_mission_id = "mission_01_pre_des_mots"
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
ProfileManager.delete_profile(_profile_id)
|
||||
GameManager.pending_mission_id = ""
|
||||
|
||||
|
||||
func _open_host() -> Control:
|
||||
var host: Control = load("res://scenes/exercises/exercise_host.tscn").instantiate()
|
||||
add_child_autofree(host)
|
||||
await wait_process_frames(1)
|
||||
host.get_node("%OverlayContainer").get_child(0).continued.emit()
|
||||
await wait_process_frames(1)
|
||||
return host
|
||||
|
||||
|
||||
func _answer(host: Control, correctly: bool) -> void:
|
||||
var exercise: Dictionary = host.get_current_exercise()
|
||||
var correct: int = exercise["correct_answer"]
|
||||
var choice := correct if correctly else (correct + 1) % (exercise["answers"] as Array).size()
|
||||
host.get_node("%ExerciseContainer").get_child(0).answer_submitted.emit(choice)
|
||||
await wait_process_frames(1)
|
||||
var overlay: Control = host.get_node("%OverlayContainer")
|
||||
overlay.get_child(overlay.get_child_count() - 1).get_node("%ContinueButton").pressed.emit()
|
||||
await wait_process_frames(1)
|
||||
|
||||
|
||||
func test_correct_first_try_then_found_with_help_then_revealed() -> void:
|
||||
var host: Control = await _open_host()
|
||||
|
||||
await _answer(host, true)
|
||||
await _answer(host, false)
|
||||
await _answer(host, true)
|
||||
await _answer(host, false)
|
||||
await _answer(host, false)
|
||||
await _answer(host, false)
|
||||
|
||||
assert_eq(
|
||||
host.get_outcomes(),
|
||||
[
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
MissionScoring.OUTCOME_WITH_HELP,
|
||||
MissionScoring.OUTCOME_REVEALED,
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkucbjqnwv2oj
|
||||
@@ -0,0 +1,25 @@
|
||||
extends GutTest
|
||||
## Tests de MissionScoring : le score valorise la reussite sans aide, jamais
|
||||
## la vitesse, et une mission terminee rapporte toujours au moins 1 etoile.
|
||||
|
||||
const FIRST := MissionScoring.OUTCOME_FIRST_TRY
|
||||
const HELP := MissionScoring.OUTCOME_WITH_HELP
|
||||
const REVEALED := MissionScoring.OUTCOME_REVEALED
|
||||
|
||||
|
||||
func test_all_first_try_gives_three_stars() -> void:
|
||||
assert_eq(MissionScoring.stars_for([FIRST, FIRST, FIRST]), 3)
|
||||
|
||||
|
||||
func test_some_help_but_everything_found_gives_two_stars() -> void:
|
||||
assert_eq(MissionScoring.stars_for([FIRST, HELP, FIRST]), 2)
|
||||
assert_eq(MissionScoring.stars_for([HELP, HELP, HELP]), 2)
|
||||
|
||||
|
||||
func test_a_revealed_answer_gives_one_star() -> void:
|
||||
assert_eq(MissionScoring.stars_for([FIRST, FIRST, REVEALED]), 1)
|
||||
assert_eq(MissionScoring.stars_for([REVEALED, REVEALED, REVEALED]), 1)
|
||||
|
||||
|
||||
func test_a_finished_mission_never_gives_zero_stars() -> void:
|
||||
assert_eq(MissionScoring.stars_for([]), 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://m13iutw7bsyc
|
||||
@@ -0,0 +1,34 @@
|
||||
extends GutTest
|
||||
## Tests de RewardRules : deblocage des objets selon le total d'etoiles ou le
|
||||
## meilleur score d'une mission.
|
||||
|
||||
var _catalog: Array[Dictionary] = [
|
||||
{"item_id": "item_total_3", "unlock": {"type": "total_stars", "count": 3}},
|
||||
{"item_id": "item_total_6", "unlock": {"type": "total_stars", "count": 6}},
|
||||
{
|
||||
"item_id": "item_trophy",
|
||||
"unlock": {"type": "mission_stars", "count": 3, "mission_id": "mission_a"}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
func test_nothing_unlocks_with_no_stars() -> void:
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 0, {}, []), [])
|
||||
|
||||
|
||||
func test_total_stars_threshold_unlocks_items_reached() -> void:
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 3, {}, []), ["item_total_3"])
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 7, {}, []), ["item_total_3", "item_total_6"])
|
||||
|
||||
|
||||
func test_owned_items_are_not_unlocked_again() -> void:
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 7, {}, ["item_total_3"]), ["item_total_6"])
|
||||
|
||||
|
||||
func test_mission_trophy_needs_the_best_score_on_that_mission() -> void:
|
||||
var two_stars := {"mission_a": {"best_stars": 2}}
|
||||
var three_stars := {"mission_a": {"best_stars": 3}}
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 0, two_stars, []), [])
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 0, three_stars, []), ["item_trophy"])
|
||||
var other_mission := {"mission_b": {"best_stars": 3}}
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 0, other_mission, []), [])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwfmot2u0aj2r
|
||||
@@ -83,8 +83,44 @@ func test_next_difficulty_for_unknown_skill_returns_current_difficulty() -> void
|
||||
assert_eq(ProgressUpdates.next_difficulty_for_skill(data, "inconnue", 2), 2)
|
||||
|
||||
|
||||
func test_add_stars_accumulates() -> void:
|
||||
var data := _base_save()
|
||||
data = ProgressUpdates.add_stars(data, 1)
|
||||
data = ProgressUpdates.add_stars(data, 2)
|
||||
func test_first_mission_result_adds_its_stars_to_the_total() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 2)
|
||||
assert_eq(data["rewards"]["stars"], 2)
|
||||
assert_eq(
|
||||
data["mission_results"]["mission_a"], {"best_stars": 2, "last_stars": 2, "completions": 1}
|
||||
)
|
||||
|
||||
|
||||
func test_replaying_a_mission_only_adds_the_improvement() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 1)
|
||||
data = ProgressUpdates.record_mission_result(data, "mission_a", 3)
|
||||
assert_eq(data["rewards"]["stars"], 3, "1 puis 3 etoiles : 3 au total, pas 4")
|
||||
assert_eq(data["mission_results"]["mission_a"]["best_stars"], 3)
|
||||
assert_eq(data["mission_results"]["mission_a"]["completions"], 2)
|
||||
|
||||
|
||||
func test_a_worse_replay_never_lowers_the_best_score() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 3)
|
||||
data = ProgressUpdates.record_mission_result(data, "mission_a", 1)
|
||||
assert_eq(data["rewards"]["stars"], 3)
|
||||
assert_eq(data["mission_results"]["mission_a"]["best_stars"], 3)
|
||||
assert_eq(data["mission_results"]["mission_a"]["last_stars"], 1)
|
||||
|
||||
|
||||
func test_stars_of_different_missions_add_up() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 2)
|
||||
data = ProgressUpdates.record_mission_result(data, "mission_b", 3)
|
||||
assert_eq(data["rewards"]["stars"], 5)
|
||||
|
||||
|
||||
func test_grant_items_never_duplicates() -> void:
|
||||
var ids: Array[String] = ["item_a", "item_b"]
|
||||
var data := ProgressUpdates.grant_items(_base_save(), ids)
|
||||
data = ProgressUpdates.grant_items(data, ids)
|
||||
assert_eq(data["rewards"]["items"], ["item_a", "item_b"])
|
||||
|
||||
|
||||
func test_record_mission_result_validates_against_the_save_schema() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 2)
|
||||
var errors := SchemaValidator.validate(data, DataLoader.load_schema("save"))
|
||||
assert_eq(errors, [])
|
||||
|
||||
@@ -3,12 +3,26 @@ extends GutTest
|
||||
|
||||
|
||||
func test_current_version_is_returned_unchanged() -> void:
|
||||
var data := {"schema_version": 1, "profile_id": "profile_test"}
|
||||
var data := {"schema_version": 2, "profile_id": "profile_test", "mission_results": {}}
|
||||
var migrated := SaveMigration.migrate(data)
|
||||
assert_eq(migrated["schema_version"], 1)
|
||||
assert_eq(migrated["schema_version"], 2)
|
||||
assert_eq(migrated["profile_id"], "profile_test")
|
||||
|
||||
|
||||
func test_version_1_save_gains_empty_mission_results_and_keeps_its_stars() -> void:
|
||||
var data := SaveFactory.new_save("profile_test", "Explorateur", "CE1")
|
||||
data["schema_version"] = 1
|
||||
data.erase("mission_results")
|
||||
data["rewards"] = {"stars": 4, "items": ["item_a"]}
|
||||
|
||||
var migrated := SaveMigration.migrate(data)
|
||||
|
||||
assert_eq(migrated["schema_version"], 2)
|
||||
assert_eq(migrated["mission_results"], {})
|
||||
assert_eq(migrated["rewards"], {"stars": 4, "items": ["item_a"]})
|
||||
assert_eq(SchemaValidator.validate(migrated, DataLoader.load_schema("save")), [])
|
||||
|
||||
|
||||
func test_missing_version_is_rejected() -> void:
|
||||
var data := {"profile_id": "profile_test"}
|
||||
assert_eq(SaveMigration.migrate(data), {})
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
extends GutTest
|
||||
## Fin de mission de bout en bout cote interface : l'ecran de recompense
|
||||
## enregistre le score, debloque des objets et la collection les affiche. Le
|
||||
## changement de scene final n'est jamais declenche ici (voir point 14 de
|
||||
## docs/specs/decisions.md).
|
||||
|
||||
var _profile_id: String = ""
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
_profile_id = ProfileManager.create_profile("Test", "CE1")
|
||||
ProfileManager.select_profile(_profile_id)
|
||||
GameManager.pending_mission_id = "mission_01_pre_des_mots"
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
ProfileManager.delete_profile(_profile_id)
|
||||
GameManager.pending_mission_id = ""
|
||||
GameManager.last_outcomes = []
|
||||
|
||||
|
||||
func _open_reward_screen(outcomes: Array[String]) -> Control:
|
||||
GameManager.last_outcomes = outcomes
|
||||
var screen: Control = load("res://scenes/ui/reward_screen.tscn").instantiate()
|
||||
add_child_autofree(screen)
|
||||
return screen
|
||||
|
||||
|
||||
func test_perfect_mission_gives_three_stars_and_the_trophy() -> void:
|
||||
var screen := _open_reward_screen(
|
||||
[
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
]
|
||||
)
|
||||
await wait_process_frames(1)
|
||||
|
||||
assert_string_contains(screen.get_node("%StarsLabel").text, "3/3")
|
||||
assert_eq(ProgressManager.mission_best_stars("mission_01_pre_des_mots"), 3)
|
||||
assert_eq(ProgressManager.stars(), 3)
|
||||
assert_true(ProgressManager.owned_items().has("item_graine_magique"))
|
||||
assert_true(ProgressManager.owned_items().has("item_trophee_pre"))
|
||||
assert_true(screen.get_node("%NewItemsLabel").visible)
|
||||
assert_false(screen.get_node("%RecordLabel").visible, "premiere fois : pas de record")
|
||||
|
||||
|
||||
func test_revealed_answer_still_rewards_one_star_without_blame() -> void:
|
||||
var screen := _open_reward_screen(
|
||||
[
|
||||
MissionScoring.OUTCOME_REVEALED,
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
MissionScoring.OUTCOME_WITH_HELP,
|
||||
]
|
||||
)
|
||||
await wait_process_frames(1)
|
||||
|
||||
assert_string_contains(screen.get_node("%StarsLabel").text, "1/3")
|
||||
assert_eq(ProgressManager.stars(), 1)
|
||||
assert_eq(screen.get_node("%MessageLabel").text, UIText.get_text("reward_screen.message_1"))
|
||||
|
||||
|
||||
func test_replaying_with_a_better_score_shows_a_record() -> void:
|
||||
var screen_one := _open_reward_screen([MissionScoring.OUTCOME_REVEALED])
|
||||
await wait_process_frames(1)
|
||||
screen_one.queue_free()
|
||||
|
||||
var screen_two := _open_reward_screen([MissionScoring.OUTCOME_FIRST_TRY])
|
||||
await wait_process_frames(1)
|
||||
|
||||
assert_true(screen_two.get_node("%RecordLabel").visible)
|
||||
assert_eq(ProgressManager.stars(), 3)
|
||||
|
||||
|
||||
func test_collection_lists_owned_and_mysterious_items() -> void:
|
||||
var perfect: Array[String] = [MissionScoring.OUTCOME_FIRST_TRY]
|
||||
_open_reward_screen(perfect)
|
||||
await wait_process_frames(1)
|
||||
|
||||
var collection: Control = load("res://scenes/ui/collection.tscn").instantiate()
|
||||
add_child_autofree(collection)
|
||||
await wait_process_frames(1)
|
||||
|
||||
var rows := collection.get_node("%ItemsBox").get_children()
|
||||
assert_eq(rows.size(), ExerciseEngine.get_reward_items().size())
|
||||
assert_string_contains((rows[0] as Label).text, "Graine magique")
|
||||
assert_string_contains((rows[1] as Label).text, UIText.get_text("collection.locked_title"))
|
||||
@@ -0,0 +1 @@
|
||||
uid://nvfqkplsx2fl
|
||||
Reference in New Issue
Block a user