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
+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