Files
aventuriers-des-mots/autoload/exercise_engine.gd
T
2026-10-12 11:05:00 +02:00

255 lines
10 KiB
GDScript

extends Node
## Chargement et deroulement des exercices a partir des donnees JSON validees.
## Valide chaque fichier au chargement avec le meme validateur que
## `make validate-data` (DataLoader/SchemaValidator, scripts/data/) : un
## fichier invalide est ignore (avec une erreur journalisee), jamais charge
## tel quel. Le tirage au sort d'un exercice est delegue a ExercisePicker,
## pur, avec un generateur aleatoire possede ici (voir CLAUDE.md : le hasard
## est injecte dans la logique pure, pas lu directement par elle).
var _rules: Dictionary = {}
var _exercises_by_rule_and_type: Dictionary = {}
var _dictations_by_rule: Dictionary = {}
var _reward_items: Array[Dictionary] = []
var _regions: Dictionary = {}
var _region_order: Array[String] = []
var _missions: Dictionary = {}
var _mission_order: Array[String] = []
var _rng := RandomNumberGenerator.new()
func _ready() -> void:
_rng.randomize()
_load_rules()
_load_regions()
_load_exercises()
_load_dictations()
_load_missions()
_load_rewards()
func get_rule(rule_id: String) -> Dictionary:
return _rules.get(rule_id, {})
func get_missions() -> Array[Dictionary]:
var missions: Array[Dictionary] = []
for mission_id in _mission_order:
missions.append(_missions[mission_id])
return missions
## Missions dont la regle appartient au niveau scolaire demande (le niveau
## d'une mission est celui de sa regle, pas un champ de plus a tenir a jour).
func get_missions_for_level(level: String) -> Array[Dictionary]:
var missions: Array[Dictionary] = []
for mission in get_missions():
if get_rule(mission["rule_id"]).get("level", "") == level:
missions.append(mission)
return missions
## « Tableaux » (regions de la carte) du niveau, dans l'ordre d'affichage :
## chacun est un fichier de data/curriculum/ (titre, decor, regles).
func get_regions_for_level(level: String) -> Array[Dictionary]:
var regions: Array[Dictionary] = []
for region_id in _region_order:
if _regions[region_id]["level"] == level:
regions.append(_regions[region_id])
return regions
func get_region(region_id: String) -> Dictionary:
return _regions.get(region_id, {})
## Tableau auquel appartient une regle ("" si aucun).
func region_id_for_rule(rule_id: String) -> String:
for region_id in _region_order:
if (_regions[region_id]["rule_ids"] as Array).has(rule_id):
return region_id
return ""
## Missions d'un tableau : celles dont la regle figure dans ce tableau.
func get_missions_for_region(region_id: String) -> Array[Dictionary]:
var missions: Array[Dictionary] = []
var rule_ids: Array = _regions.get(region_id, {}).get("rule_ids", [])
for mission in get_missions():
if rule_ids.has(mission["rule_id"]):
missions.append(mission)
return missions
## Decor (BackdropKinds) du tableau d'une regle ; a defaut, celui du niveau.
func backdrop_for_rule(rule_id: String) -> String:
var region := get_region(region_id_for_rule(rule_id))
var kind: String = region.get("backdrop", "")
if BackdropKinds.is_valid(kind):
return kind
return "marsh" if get_rule(rule_id).get("level", "") == "CE2" else BackdropKinds.FALLBACK
## 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, {})
## Tire le prochain exercice (ou dictee) pour une mission, au plus proche de
## `difficulty`, sans redonner `exclude_id`. Dictionnaire vide si la mission
## n'a aucun contenu disponible.
func pick_exercise_for_mission(
mission: Dictionary, exclude_id: String, difficulty: int
) -> Dictionary:
var rule_id: String = mission["rule_id"]
var exercise_type: String = mission["exercise_type"]
var candidates: Array[Dictionary] = _candidates_for(rule_id, exercise_type)
var filtered := _filter_by_difficulty(candidates, difficulty)
var picked: Variant = ExercisePicker.pick(filtered, exclude_id, _rng)
return picked if picked != null else {}
## Comme pick_exercise_for_mission, mais ne redonne jamais un exercice deja vu
## pendant la mission tant qu'il en reste un autre (un enfant ne doit pas voir
## deux fois la meme phrase dans une meme mission).
func pick_unseen_exercise_for_mission(
mission: Dictionary, seen_ids: Array[String], difficulty: int
) -> Dictionary:
var candidates: Array[Dictionary] = _candidates_for(
mission["rule_id"], mission["exercise_type"]
)
var picked: Variant = ExercisePicker.pick_unseen(
_filter_by_difficulty(candidates, difficulty), candidates, seen_ids, _rng
)
return picked if picked != null else {}
## Ne garde que les candidats a la difficulte demandee ; si aucun ne la
## propose, retombe sur l'ensemble des candidats plutot que de n'en donner
## aucun (le contenu disponible est encore limite, voir docs/specs/decisions.md).
func _filter_by_difficulty(candidates: Array[Dictionary], difficulty: int) -> Array[Dictionary]:
var exact: Array[Dictionary] = []
for candidate in candidates:
if int(candidate.get("difficulty", difficulty)) == difficulty:
exact.append(candidate)
return exact if not exact.is_empty() else candidates
func _candidates_for(rule_id: String, exercise_type: String) -> Array[Dictionary]:
if exercise_type == "dictation":
return _dictations_by_rule.get(rule_id, [] as Array[Dictionary])
var by_type: Dictionary = _exercises_by_rule_and_type.get(rule_id, {})
return by_type.get(exercise_type, [] as Array[Dictionary])
func _load_rules() -> void:
for path in DataLoader.find_json_files("res://data/rules"):
var errors := DataLoader.validate_file(path, "rule")
if not errors.is_empty():
push_error("Regle ignoree (%s) : %s" % [path, errors])
continue
var rule: Dictionary = DataLoader.read_json(path)
_rules[rule["rule_id"]] = rule
func _load_regions() -> void:
var loaded: Array[Dictionary] = []
for path in DataLoader.find_json_files("res://data/curriculum"):
var errors := DataLoader.validate_file(path, "curriculum")
if not errors.is_empty():
push_error("Tableau ignore (%s) : %s" % [path, errors])
continue
loaded.append(DataLoader.read_json(path))
# Ordre : niveau, puis numero de tableau, puis identifiant (stable).
loaded.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool:
var rank_a := SchoolLevels.rank(a["level"])
var rank_b := SchoolLevels.rank(b["level"])
if rank_a != rank_b:
return rank_a < rank_b
var order_a := int(a.get("order", 1))
var order_b := int(b.get("order", 1))
if order_a != order_b:
return order_a < order_b
return a["region_id"] < b["region_id"]
)
for region in loaded:
_regions[region["region_id"]] = region
_region_order.append(region["region_id"])
func _load_exercises() -> void:
for path in DataLoader.find_json_files("res://data/exercises"):
var raw: Variant = DataLoader.read_json(path)
if typeof(raw) != TYPE_DICTIONARY or not raw.has("type"):
push_error("Exercice ignore (%s) : type manquant ou JSON invalide" % path)
continue
var exercise: Dictionary = raw
var exercise_type: String = exercise["type"]
var schema_name := DataLoader.exercise_schema_name_for_type(exercise_type)
if schema_name.is_empty():
push_error("Exercice ignore (%s) : type inconnu '%s'" % [path, exercise_type])
continue
var errors := SchemaValidator.validate(exercise, DataLoader.load_schema(schema_name))
if not errors.is_empty():
push_error("Exercice ignore (%s) : %s" % [path, errors])
continue
var rule_id: String = exercise["rule_id"]
if not _exercises_by_rule_and_type.has(rule_id):
_exercises_by_rule_and_type[rule_id] = {}
var by_type: Dictionary = _exercises_by_rule_and_type[rule_id]
if not by_type.has(exercise_type):
by_type[exercise_type] = [] as Array[Dictionary]
(by_type[exercise_type] as Array[Dictionary]).append(exercise)
func _load_dictations() -> void:
for path in DataLoader.find_json_files("res://data/dictations"):
var errors := DataLoader.validate_file(path, "dictation")
if not errors.is_empty():
push_error("Dictee ignoree (%s) : %s" % [path, errors])
continue
var dictation: Dictionary = DataLoader.read_json(path)
for rule_id: String in dictation["rule_ids"]:
if not _dictations_by_rule.has(rule_id):
_dictations_by_rule[rule_id] = [] as Array[Dictionary]
(_dictations_by_rule[rule_id] as Array[Dictionary]).append(dictation)
func _load_missions() -> void:
var mission_ids: Array[String] = []
for path in DataLoader.find_json_files("res://data/missions"):
var errors := DataLoader.validate_file(path, "mission")
if not errors.is_empty():
push_error("Mission ignoree (%s) : %s" % [path, errors])
continue
var mission: Dictionary = DataLoader.read_json(path)
_missions[mission["mission_id"]] = mission
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"])
)