diff --git a/autoload/exercise_engine.gd b/autoload/exercise_engine.gd index db1f1f0..69cfcbf 100644 --- a/autoload/exercise_engine.gd +++ b/autoload/exercise_engine.gd @@ -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"]) + ) diff --git a/autoload/game_manager.gd b/autoload/game_manager.gd index dcd221d..ea954cb 100644 --- a/autoload/game_manager.gd +++ b/autoload/game_manager.gd @@ -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 diff --git a/autoload/progress_manager.gd b/autoload/progress_manager.gd index 9748e3c..00a06ca 100644 --- a/autoload/progress_manager.gd +++ b/autoload/progress_manager.gd @@ -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: diff --git a/data/localization/fr.json b/data/localization/fr.json index 634882d..b0e0379 100644 --- a/data/localization/fr.json +++ b/data/localization/fr.json @@ -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" } diff --git a/data/rewards/item_carte_ancienne.json b/data/rewards/item_carte_ancienne.json new file mode 100644 index 0000000..50b9575 --- /dev/null +++ b/data/rewards/item_carte_ancienne.json @@ -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" +} diff --git a/data/rewards/item_cle_argentee.json b/data/rewards/item_cle_argentee.json new file mode 100644 index 0000000..854c692 --- /dev/null +++ b/data/rewards/item_cle_argentee.json @@ -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" +} diff --git a/data/rewards/item_couronne_feuilles.json b/data/rewards/item_couronne_feuilles.json new file mode 100644 index 0000000..0d1878c --- /dev/null +++ b/data/rewards/item_couronne_feuilles.json @@ -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" +} diff --git a/data/rewards/item_cristal_echo.json b/data/rewards/item_cristal_echo.json new file mode 100644 index 0000000..4d6f667 --- /dev/null +++ b/data/rewards/item_cristal_echo.json @@ -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" +} diff --git a/data/rewards/item_graine_magique.json b/data/rewards/item_graine_magique.json new file mode 100644 index 0000000..3429a1d --- /dev/null +++ b/data/rewards/item_graine_magique.json @@ -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" +} diff --git a/data/rewards/item_lanterne_foret.json b/data/rewards/item_lanterne_foret.json new file mode 100644 index 0000000..bee121a --- /dev/null +++ b/data/rewards/item_lanterne_foret.json @@ -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" +} diff --git a/data/rewards/item_plume_doree.json b/data/rewards/item_plume_doree.json new file mode 100644 index 0000000..953269d --- /dev/null +++ b/data/rewards/item_plume_doree.json @@ -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" +} diff --git a/data/rewards/item_trophee_clairiere.json b/data/rewards/item_trophee_clairiere.json new file mode 100644 index 0000000..208cc14 --- /dev/null +++ b/data/rewards/item_trophee_clairiere.json @@ -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" +} diff --git a/data/rewards/item_trophee_pont.json b/data/rewards/item_trophee_pont.json new file mode 100644 index 0000000..a118e46 --- /dev/null +++ b/data/rewards/item_trophee_pont.json @@ -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" +} diff --git a/data/rewards/item_trophee_pre.json b/data/rewards/item_trophee_pre.json new file mode 100644 index 0000000..618c51c --- /dev/null +++ b/data/rewards/item_trophee_pre.json @@ -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" +} diff --git a/data/schemas/reward_item.schema.json b/data/schemas/reward_item.schema.json new file mode 100644 index 0000000..0b77f1e --- /dev/null +++ b/data/schemas/reward_item.schema.json @@ -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"] } + } +} diff --git a/data/schemas/save.schema.json b/data/schemas/save.schema.json index 2f49f41..f03f570 100644 --- a/data/schemas/save.schema.json +++ b/data/schemas/save.schema.json @@ -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"], diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md index 2b1eb5a..00a8e24 100644 --- a/docs/specs/decisions.md +++ b/docs/specs/decisions.md @@ -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. diff --git a/docs/testing/manual-checklist.md b/docs/testing/manual-checklist.md index 26fb3d0..6facfec 100644 --- a/docs/testing/manual-checklist.md +++ b/docs/testing/manual-checklist.md @@ -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. diff --git a/project.godot b/project.godot index cfb6ce0..3dc8b4d 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scenes/exercises/exercise_host.gd b/scenes/exercises/exercise_host.gd index 0b5da0e..454b3ac 100644 --- a/scenes/exercises/exercise_host.gd +++ b/scenes/exercises/exercise_host.gd @@ -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() diff --git a/scenes/ui/collection.gd b/scenes/ui/collection.gd new file mode 100644 index 0000000..445cec5 --- /dev/null +++ b/scenes/ui/collection.gd @@ -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) diff --git a/scenes/ui/collection.gd.uid b/scenes/ui/collection.gd.uid new file mode 100644 index 0000000..d2ad450 --- /dev/null +++ b/scenes/ui/collection.gd.uid @@ -0,0 +1 @@ +uid://b1bk5xhhar5nj diff --git a/scenes/ui/collection.tscn b/scenes/ui/collection.tscn new file mode 100644 index 0000000..bcddfa7 --- /dev/null +++ b/scenes/ui/collection.tscn @@ -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) diff --git a/scenes/ui/reward_screen.gd b/scenes/ui/reward_screen.gd index f205752..7bcf810 100644 --- a/scenes/ui/reward_screen.gd +++ b/scenes/ui/reward_screen.gd @@ -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) diff --git a/scenes/ui/reward_screen.tscn b/scenes/ui/reward_screen.tscn index 481c581..b8100cd 100644 --- a/scenes/ui/reward_screen.tscn +++ b/scenes/ui/reward_screen.tscn @@ -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 diff --git a/scenes/world/world_map.gd b/scenes/world/world_map.gd index ae134d9..70285c6 100644 --- a/scenes/world/world_map.gd +++ b/scenes/world/world_map.gd @@ -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"] diff --git a/scenes/world/world_map.tscn b/scenes/world/world_map.tscn index e41eb2a..1ffa1b6 100644 --- a/scenes/world/world_map.tscn +++ b/scenes/world/world_map.tscn @@ -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 diff --git a/scripts/data/semantic_checks.gd b/scripts/data/semantic_checks.gd index 45aa136..9d337ef 100644 --- a/scripts/data/semantic_checks.gd +++ b/scripts/data/semantic_checks.gd @@ -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 diff --git a/scripts/data/validate_data_cli.gd b/scripts/data/validate_data_cli.gd index af16de8..070126a 100644 --- a/scripts/data/validate_data_cli.gd +++ b/scripts/data/validate_data_cli.gd @@ -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 diff --git a/scripts/pedagogy/mission_scoring.gd b/scripts/pedagogy/mission_scoring.gd new file mode 100644 index 0000000..a898f3f --- /dev/null +++ b/scripts/pedagogy/mission_scoring.gd @@ -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 diff --git a/scripts/pedagogy/mission_scoring.gd.uid b/scripts/pedagogy/mission_scoring.gd.uid new file mode 100644 index 0000000..09af5e9 --- /dev/null +++ b/scripts/pedagogy/mission_scoring.gd.uid @@ -0,0 +1 @@ +uid://53b456qsmdmt diff --git a/scripts/pedagogy/reward_rules.gd b/scripts/pedagogy/reward_rules.gd new file mode 100644 index 0000000..a7563ae --- /dev/null +++ b/scripts/pedagogy/reward_rules.gd @@ -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 diff --git a/scripts/pedagogy/reward_rules.gd.uid b/scripts/pedagogy/reward_rules.gd.uid new file mode 100644 index 0000000..4ded81a --- /dev/null +++ b/scripts/pedagogy/reward_rules.gd.uid @@ -0,0 +1 @@ +uid://daxy0fqgngwvj diff --git a/scripts/profiles/progress_updates.gd b/scripts/profiles/progress_updates.gd index 783969e..abee3b5 100644 --- a/scripts/profiles/progress_updates.gd +++ b/scripts/profiles/progress_updates.gd @@ -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 diff --git a/scripts/profiles/save_factory.gd b/scripts/profiles/save_factory.gd index ccef906..1f12e38 100644 --- a/scripts/profiles/save_factory.gd +++ b/scripts/profiles/save_factory.gd @@ -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": []}, } diff --git a/scripts/profiles/save_migration.gd b/scripts/profiles/save_migration.gd index 30c0f81..fe3d8be 100644 --- a/scripts/profiles/save_migration.gd +++ b/scripts/profiles/save_migration.gd @@ -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 diff --git a/tests/exercises/test_exercise_engine.gd b/tests/exercises/test_exercise_engine.gd index 4faa105..4669a76 100644 --- a/tests/exercises/test_exercise_engine.gd +++ b/tests/exercises/test_exercise_engine.gd @@ -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") diff --git a/tests/exercises/test_exercise_host_outcomes.gd b/tests/exercises/test_exercise_host_outcomes.gd new file mode 100644 index 0000000..1ed66f9 --- /dev/null +++ b/tests/exercises/test_exercise_host_outcomes.gd @@ -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, + ] + ) diff --git a/tests/exercises/test_exercise_host_outcomes.gd.uid b/tests/exercises/test_exercise_host_outcomes.gd.uid new file mode 100644 index 0000000..4547b61 --- /dev/null +++ b/tests/exercises/test_exercise_host_outcomes.gd.uid @@ -0,0 +1 @@ +uid://dkucbjqnwv2oj diff --git a/tests/pedagogy/test_mission_scoring.gd b/tests/pedagogy/test_mission_scoring.gd new file mode 100644 index 0000000..2225bdb --- /dev/null +++ b/tests/pedagogy/test_mission_scoring.gd @@ -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) diff --git a/tests/pedagogy/test_mission_scoring.gd.uid b/tests/pedagogy/test_mission_scoring.gd.uid new file mode 100644 index 0000000..b03d9c7 --- /dev/null +++ b/tests/pedagogy/test_mission_scoring.gd.uid @@ -0,0 +1 @@ +uid://m13iutw7bsyc diff --git a/tests/pedagogy/test_reward_rules.gd b/tests/pedagogy/test_reward_rules.gd new file mode 100644 index 0000000..121181d --- /dev/null +++ b/tests/pedagogy/test_reward_rules.gd @@ -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, []), []) diff --git a/tests/pedagogy/test_reward_rules.gd.uid b/tests/pedagogy/test_reward_rules.gd.uid new file mode 100644 index 0000000..bceea2e --- /dev/null +++ b/tests/pedagogy/test_reward_rules.gd.uid @@ -0,0 +1 @@ +uid://bwfmot2u0aj2r diff --git a/tests/profiles/test_progress_updates.gd b/tests/profiles/test_progress_updates.gd index de0437d..503ead2 100644 --- a/tests/profiles/test_progress_updates.gd +++ b/tests/profiles/test_progress_updates.gd @@ -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, []) diff --git a/tests/profiles/test_save_migration.gd b/tests/profiles/test_save_migration.gd index a893ee6..50bca02 100644 --- a/tests/profiles/test_save_migration.gd +++ b/tests/profiles/test_save_migration.gd @@ -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), {}) diff --git a/tests/world/test_reward_flow.gd b/tests/world/test_reward_flow.gd new file mode 100644 index 0000000..9c78032 --- /dev/null +++ b/tests/world/test_reward_flow.gd @@ -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")) diff --git a/tests/world/test_reward_flow.gd.uid b/tests/world/test_reward_flow.gd.uid new file mode 100644 index 0000000..01de295 --- /dev/null +++ b/tests/world/test_reward_flow.gd.uid @@ -0,0 +1 @@ +uid://nvfqkplsx2fl