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"]) )