mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 02:11:58 +02:00
Add mission scoring, rewards collection and save schema v2
Phase "score et récompenses" of the post-0.1.0 plan (user feedback: wants a more game-like experience with scoring and rewards). - MissionScoring: 1-3 stars from how each exercise was solved (never speed; a finished mission always gives at least one star). - Save schema v2: mission_results (best/last stars, completions) with a v1->v2 migration that keeps existing stars and items. - Total only grows by the improvement over a mission's best score. - Data-driven collection (data/rewards, reward_item schema, validated incl. mission cross-check) unlocked by RewardRules. - Reward screen, collection screen, stars on the world map. - Version 0.2.0. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,16 @@ func test_missions_are_filtered_by_the_level_of_their_rule() -> void:
|
||||
assert_eq(ExerciseEngine.get_missions_for_level("CM1").size(), 0)
|
||||
|
||||
|
||||
func test_reward_catalog_is_loaded_and_sorted_by_threshold() -> void:
|
||||
var items := ExerciseEngine.get_reward_items()
|
||||
assert_eq(items.size(), 10)
|
||||
var counts: Array = items.map(func(i: Dictionary) -> int: return i["unlock"]["count"])
|
||||
var sorted_counts := counts.duplicate()
|
||||
sorted_counts.sort()
|
||||
assert_eq(counts, sorted_counts)
|
||||
assert_eq(items[0]["item_id"], "item_graine_magique", "seuil 3, ordre alphabetique")
|
||||
|
||||
|
||||
func test_get_mission_returns_the_requested_mission() -> void:
|
||||
var mission := ExerciseEngine.get_mission("mission_02_pont_des_phrases")
|
||||
assert_eq(mission["exercise_type"], "sentence_correction")
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
extends GutTest
|
||||
## exercise_host note, pour chaque exercice termine, comment il a ete resolu
|
||||
## (du premier coup, avec aide, reponse montree) : c'est ce qui alimente le
|
||||
## score de la mission (MissionScoring). Le changement de scene final n'est
|
||||
## jamais atteint ici.
|
||||
|
||||
var _profile_id: String = ""
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
_profile_id = ProfileManager.create_profile("Test", "CE1")
|
||||
ProfileManager.select_profile(_profile_id)
|
||||
GameManager.pending_mission_id = "mission_01_pre_des_mots"
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
ProfileManager.delete_profile(_profile_id)
|
||||
GameManager.pending_mission_id = ""
|
||||
|
||||
|
||||
func _open_host() -> Control:
|
||||
var host: Control = load("res://scenes/exercises/exercise_host.tscn").instantiate()
|
||||
add_child_autofree(host)
|
||||
await wait_process_frames(1)
|
||||
host.get_node("%OverlayContainer").get_child(0).continued.emit()
|
||||
await wait_process_frames(1)
|
||||
return host
|
||||
|
||||
|
||||
func _answer(host: Control, correctly: bool) -> void:
|
||||
var exercise: Dictionary = host.get_current_exercise()
|
||||
var correct: int = exercise["correct_answer"]
|
||||
var choice := correct if correctly else (correct + 1) % (exercise["answers"] as Array).size()
|
||||
host.get_node("%ExerciseContainer").get_child(0).answer_submitted.emit(choice)
|
||||
await wait_process_frames(1)
|
||||
var overlay: Control = host.get_node("%OverlayContainer")
|
||||
overlay.get_child(overlay.get_child_count() - 1).get_node("%ContinueButton").pressed.emit()
|
||||
await wait_process_frames(1)
|
||||
|
||||
|
||||
func test_correct_first_try_then_found_with_help_then_revealed() -> void:
|
||||
var host: Control = await _open_host()
|
||||
|
||||
await _answer(host, true)
|
||||
await _answer(host, false)
|
||||
await _answer(host, true)
|
||||
await _answer(host, false)
|
||||
await _answer(host, false)
|
||||
await _answer(host, false)
|
||||
|
||||
assert_eq(
|
||||
host.get_outcomes(),
|
||||
[
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
MissionScoring.OUTCOME_WITH_HELP,
|
||||
MissionScoring.OUTCOME_REVEALED,
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkucbjqnwv2oj
|
||||
@@ -0,0 +1,25 @@
|
||||
extends GutTest
|
||||
## Tests de MissionScoring : le score valorise la reussite sans aide, jamais
|
||||
## la vitesse, et une mission terminee rapporte toujours au moins 1 etoile.
|
||||
|
||||
const FIRST := MissionScoring.OUTCOME_FIRST_TRY
|
||||
const HELP := MissionScoring.OUTCOME_WITH_HELP
|
||||
const REVEALED := MissionScoring.OUTCOME_REVEALED
|
||||
|
||||
|
||||
func test_all_first_try_gives_three_stars() -> void:
|
||||
assert_eq(MissionScoring.stars_for([FIRST, FIRST, FIRST]), 3)
|
||||
|
||||
|
||||
func test_some_help_but_everything_found_gives_two_stars() -> void:
|
||||
assert_eq(MissionScoring.stars_for([FIRST, HELP, FIRST]), 2)
|
||||
assert_eq(MissionScoring.stars_for([HELP, HELP, HELP]), 2)
|
||||
|
||||
|
||||
func test_a_revealed_answer_gives_one_star() -> void:
|
||||
assert_eq(MissionScoring.stars_for([FIRST, FIRST, REVEALED]), 1)
|
||||
assert_eq(MissionScoring.stars_for([REVEALED, REVEALED, REVEALED]), 1)
|
||||
|
||||
|
||||
func test_a_finished_mission_never_gives_zero_stars() -> void:
|
||||
assert_eq(MissionScoring.stars_for([]), 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://m13iutw7bsyc
|
||||
@@ -0,0 +1,34 @@
|
||||
extends GutTest
|
||||
## Tests de RewardRules : deblocage des objets selon le total d'etoiles ou le
|
||||
## meilleur score d'une mission.
|
||||
|
||||
var _catalog: Array[Dictionary] = [
|
||||
{"item_id": "item_total_3", "unlock": {"type": "total_stars", "count": 3}},
|
||||
{"item_id": "item_total_6", "unlock": {"type": "total_stars", "count": 6}},
|
||||
{
|
||||
"item_id": "item_trophy",
|
||||
"unlock": {"type": "mission_stars", "count": 3, "mission_id": "mission_a"}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
func test_nothing_unlocks_with_no_stars() -> void:
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 0, {}, []), [])
|
||||
|
||||
|
||||
func test_total_stars_threshold_unlocks_items_reached() -> void:
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 3, {}, []), ["item_total_3"])
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 7, {}, []), ["item_total_3", "item_total_6"])
|
||||
|
||||
|
||||
func test_owned_items_are_not_unlocked_again() -> void:
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 7, {}, ["item_total_3"]), ["item_total_6"])
|
||||
|
||||
|
||||
func test_mission_trophy_needs_the_best_score_on_that_mission() -> void:
|
||||
var two_stars := {"mission_a": {"best_stars": 2}}
|
||||
var three_stars := {"mission_a": {"best_stars": 3}}
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 0, two_stars, []), [])
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 0, three_stars, []), ["item_trophy"])
|
||||
var other_mission := {"mission_b": {"best_stars": 3}}
|
||||
assert_eq(RewardRules.newly_unlocked(_catalog, 0, other_mission, []), [])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwfmot2u0aj2r
|
||||
@@ -83,8 +83,44 @@ func test_next_difficulty_for_unknown_skill_returns_current_difficulty() -> void
|
||||
assert_eq(ProgressUpdates.next_difficulty_for_skill(data, "inconnue", 2), 2)
|
||||
|
||||
|
||||
func test_add_stars_accumulates() -> void:
|
||||
var data := _base_save()
|
||||
data = ProgressUpdates.add_stars(data, 1)
|
||||
data = ProgressUpdates.add_stars(data, 2)
|
||||
func test_first_mission_result_adds_its_stars_to_the_total() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 2)
|
||||
assert_eq(data["rewards"]["stars"], 2)
|
||||
assert_eq(
|
||||
data["mission_results"]["mission_a"], {"best_stars": 2, "last_stars": 2, "completions": 1}
|
||||
)
|
||||
|
||||
|
||||
func test_replaying_a_mission_only_adds_the_improvement() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 1)
|
||||
data = ProgressUpdates.record_mission_result(data, "mission_a", 3)
|
||||
assert_eq(data["rewards"]["stars"], 3, "1 puis 3 etoiles : 3 au total, pas 4")
|
||||
assert_eq(data["mission_results"]["mission_a"]["best_stars"], 3)
|
||||
assert_eq(data["mission_results"]["mission_a"]["completions"], 2)
|
||||
|
||||
|
||||
func test_a_worse_replay_never_lowers_the_best_score() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 3)
|
||||
data = ProgressUpdates.record_mission_result(data, "mission_a", 1)
|
||||
assert_eq(data["rewards"]["stars"], 3)
|
||||
assert_eq(data["mission_results"]["mission_a"]["best_stars"], 3)
|
||||
assert_eq(data["mission_results"]["mission_a"]["last_stars"], 1)
|
||||
|
||||
|
||||
func test_stars_of_different_missions_add_up() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 2)
|
||||
data = ProgressUpdates.record_mission_result(data, "mission_b", 3)
|
||||
assert_eq(data["rewards"]["stars"], 5)
|
||||
|
||||
|
||||
func test_grant_items_never_duplicates() -> void:
|
||||
var ids: Array[String] = ["item_a", "item_b"]
|
||||
var data := ProgressUpdates.grant_items(_base_save(), ids)
|
||||
data = ProgressUpdates.grant_items(data, ids)
|
||||
assert_eq(data["rewards"]["items"], ["item_a", "item_b"])
|
||||
|
||||
|
||||
func test_record_mission_result_validates_against_the_save_schema() -> void:
|
||||
var data := ProgressUpdates.record_mission_result(_base_save(), "mission_a", 2)
|
||||
var errors := SchemaValidator.validate(data, DataLoader.load_schema("save"))
|
||||
assert_eq(errors, [])
|
||||
|
||||
@@ -3,12 +3,26 @@ extends GutTest
|
||||
|
||||
|
||||
func test_current_version_is_returned_unchanged() -> void:
|
||||
var data := {"schema_version": 1, "profile_id": "profile_test"}
|
||||
var data := {"schema_version": 2, "profile_id": "profile_test", "mission_results": {}}
|
||||
var migrated := SaveMigration.migrate(data)
|
||||
assert_eq(migrated["schema_version"], 1)
|
||||
assert_eq(migrated["schema_version"], 2)
|
||||
assert_eq(migrated["profile_id"], "profile_test")
|
||||
|
||||
|
||||
func test_version_1_save_gains_empty_mission_results_and_keeps_its_stars() -> void:
|
||||
var data := SaveFactory.new_save("profile_test", "Explorateur", "CE1")
|
||||
data["schema_version"] = 1
|
||||
data.erase("mission_results")
|
||||
data["rewards"] = {"stars": 4, "items": ["item_a"]}
|
||||
|
||||
var migrated := SaveMigration.migrate(data)
|
||||
|
||||
assert_eq(migrated["schema_version"], 2)
|
||||
assert_eq(migrated["mission_results"], {})
|
||||
assert_eq(migrated["rewards"], {"stars": 4, "items": ["item_a"]})
|
||||
assert_eq(SchemaValidator.validate(migrated, DataLoader.load_schema("save")), [])
|
||||
|
||||
|
||||
func test_missing_version_is_rejected() -> void:
|
||||
var data := {"profile_id": "profile_test"}
|
||||
assert_eq(SaveMigration.migrate(data), {})
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
extends GutTest
|
||||
## Fin de mission de bout en bout cote interface : l'ecran de recompense
|
||||
## enregistre le score, debloque des objets et la collection les affiche. Le
|
||||
## changement de scene final n'est jamais declenche ici (voir point 14 de
|
||||
## docs/specs/decisions.md).
|
||||
|
||||
var _profile_id: String = ""
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
_profile_id = ProfileManager.create_profile("Test", "CE1")
|
||||
ProfileManager.select_profile(_profile_id)
|
||||
GameManager.pending_mission_id = "mission_01_pre_des_mots"
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
ProfileManager.delete_profile(_profile_id)
|
||||
GameManager.pending_mission_id = ""
|
||||
GameManager.last_outcomes = []
|
||||
|
||||
|
||||
func _open_reward_screen(outcomes: Array[String]) -> Control:
|
||||
GameManager.last_outcomes = outcomes
|
||||
var screen: Control = load("res://scenes/ui/reward_screen.tscn").instantiate()
|
||||
add_child_autofree(screen)
|
||||
return screen
|
||||
|
||||
|
||||
func test_perfect_mission_gives_three_stars_and_the_trophy() -> void:
|
||||
var screen := _open_reward_screen(
|
||||
[
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
]
|
||||
)
|
||||
await wait_process_frames(1)
|
||||
|
||||
assert_string_contains(screen.get_node("%StarsLabel").text, "3/3")
|
||||
assert_eq(ProgressManager.mission_best_stars("mission_01_pre_des_mots"), 3)
|
||||
assert_eq(ProgressManager.stars(), 3)
|
||||
assert_true(ProgressManager.owned_items().has("item_graine_magique"))
|
||||
assert_true(ProgressManager.owned_items().has("item_trophee_pre"))
|
||||
assert_true(screen.get_node("%NewItemsLabel").visible)
|
||||
assert_false(screen.get_node("%RecordLabel").visible, "premiere fois : pas de record")
|
||||
|
||||
|
||||
func test_revealed_answer_still_rewards_one_star_without_blame() -> void:
|
||||
var screen := _open_reward_screen(
|
||||
[
|
||||
MissionScoring.OUTCOME_REVEALED,
|
||||
MissionScoring.OUTCOME_FIRST_TRY,
|
||||
MissionScoring.OUTCOME_WITH_HELP,
|
||||
]
|
||||
)
|
||||
await wait_process_frames(1)
|
||||
|
||||
assert_string_contains(screen.get_node("%StarsLabel").text, "1/3")
|
||||
assert_eq(ProgressManager.stars(), 1)
|
||||
assert_eq(screen.get_node("%MessageLabel").text, UIText.get_text("reward_screen.message_1"))
|
||||
|
||||
|
||||
func test_replaying_with_a_better_score_shows_a_record() -> void:
|
||||
var screen_one := _open_reward_screen([MissionScoring.OUTCOME_REVEALED])
|
||||
await wait_process_frames(1)
|
||||
screen_one.queue_free()
|
||||
|
||||
var screen_two := _open_reward_screen([MissionScoring.OUTCOME_FIRST_TRY])
|
||||
await wait_process_frames(1)
|
||||
|
||||
assert_true(screen_two.get_node("%RecordLabel").visible)
|
||||
assert_eq(ProgressManager.stars(), 3)
|
||||
|
||||
|
||||
func test_collection_lists_owned_and_mysterious_items() -> void:
|
||||
var perfect: Array[String] = [MissionScoring.OUTCOME_FIRST_TRY]
|
||||
_open_reward_screen(perfect)
|
||||
await wait_process_frames(1)
|
||||
|
||||
var collection: Control = load("res://scenes/ui/collection.tscn").instantiate()
|
||||
add_child_autofree(collection)
|
||||
await wait_process_frames(1)
|
||||
|
||||
var rows := collection.get_node("%ItemsBox").get_children()
|
||||
assert_eq(rows.size(), ExerciseEngine.get_reward_items().size())
|
||||
assert_string_contains((rows[0] as Label).text, "Graine magique")
|
||||
assert_string_contains((rows[1] as Label).text, UIText.get_text("collection.locked_title"))
|
||||
@@ -0,0 +1 @@
|
||||
uid://nvfqkplsx2fl
|
||||
Reference in New Issue
Block a user