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