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:
Denis Defolie
2026-10-11 22:20:59 +02:00
parent 57182172a9
commit 65a0cb46f9
47 changed files with 897 additions and 36 deletions
+26
View File
@@ -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"])
)
+5
View File
@@ -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
+29 -2
View File
@@ -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:
+18 -1
View File
@@ -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"
}
+10
View File
@@ -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"
}
+10
View File
@@ -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"
}
+10
View File
@@ -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"
}
+10
View File
@@ -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"
}
+10
View File
@@ -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"
}
+10
View File
@@ -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"
}
+10
View File
@@ -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"
}
+11
View File
@@ -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"
}
+11
View File
@@ -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"
}
+11
View File
@@ -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"
}
+21
View File
@@ -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"] }
}
}
+14
View File
@@ -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"],
+18
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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"
+17
View File
@@ -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()
+54
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://b1bk5xhhar5nj
+54
View File
@@ -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
View File
@@ -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)
+29
View File
@@ -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
+23 -2
View File
@@ -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"]
+10
View File
@@ -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
+18
View File
@@ -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
+25
View File
@@ -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
+29
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://53b456qsmdmt
+34
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://daxy0fqgngwvj
+31 -5
View File
@@ -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
+1
View File
@@ -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": []},
}
+17 -9
View File
@@ -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
+10
View File
@@ -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
+25
View File
@@ -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
+34
View File
@@ -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, []), [])
+1
View File
@@ -0,0 +1 @@
uid://bwfmot2u0aj2r
+40 -4
View File
@@ -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, [])
+16 -2
View File
@@ -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), {})
+87
View File
@@ -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"))
+1
View File
@@ -0,0 +1 @@
uid://nvfqkplsx2fl