diff --git a/README-EN.md b/README-EN.md
index efad594..a6ba864 100644
--- a/README-EN.md
+++ b/README-EN.md
@@ -2,7 +2,7 @@
An educational French-language game for 7-9 year olds (French grades CE1-CE2): an offline 2D adventure with an explorable map, a guide character, and grammar, spelling, and dictation exercises.
-Open source project, in development. Current status: version 0.5.0, playable offline on Linux and Windows — profiles, saves (with export and restore in the adult area), stars and an item collection, sound effects and music, settings (volumes, text size, animations), a progress dashboard for adults, three exercise types (multiple choice, sentence correction, dictation), a CE1 chapter on noun-group agreement (3 rules) and a CE2 chapter on grammatical homophones (a/à, et/est, son/sont, on/ont), 21 missions. All pedagogical content is still `draft` (awaiting validation) and dictation voices are placeholders (see `assets/CREDITS.md`; see `docs/specs/decisions.md` for scoping decisions and `docs/specs/cahier-des-charges.md` for the full specification, both in French — this is a French-curriculum product).
+Open source project, in development. Current status: version 0.6.0, playable offline on Linux and Windows — profiles, saves (with export and restore in the adult area), stars and an item collection, sound effects and music, settings (volumes, text size, animations), a progress dashboard for adults, three exercise types (multiple choice, sentence correction, dictation), CE1 in two boards (noun-group agreement; sounds and letters) and a CE2 board on grammatical homophones (a/à, et/est, son/sont, on/ont), 30 missions; CM1 and CM2 are planned. All pedagogical content is still `draft` (awaiting validation) and dictation voices are placeholders (see `assets/CREDITS.md`; see `docs/specs/decisions.md` for scoping decisions and `docs/specs/cahier-des-charges.md` for the full specification, both in French — this is a French-curriculum product).
## Tech stack
diff --git a/README.md b/README.md
index 976e1cf..f89352c 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
Jeu éducatif de français pour les CE1-CE2 (7-9 ans) : une aventure 2D hors ligne, avec une carte à explorer, un personnage guide, et des exercices de grammaire, d'orthographe et de dictées.
-Projet open source, en développement. Statut actuel : version 0.5.0, jouable hors ligne sur Linux et Windows — profils, sauvegardes (avec export et restauration dans l'espace adulte), étoiles et collection d'objets, sons et musique, réglages (volumes, taille du texte, animations), tableau de progrès pour l'adulte, trois types d'exercices (choix multiple, correction de phrase, dictée), chapitre CE1 « accords dans le groupe nominal » (3 règles) et chapitre CE2 « homophones grammaticaux » (a/à, et/est, son/sont, on/ont), 21 missions. Contenu pédagogique entièrement en brouillon (`draft`, à valider) et voix des dictées provisoires (voir `assets/CREDITS.md` ; voir `docs/specs/decisions.md` pour les décisions de cadrage et `docs/specs/cahier-des-charges.md` pour la spécification complète).
+Projet open source, en développement. Statut actuel : version 0.6.0, jouable hors ligne sur Linux et Windows — profils, sauvegardes (avec export et restauration dans l'espace adulte), étoiles et collection d'objets, sons et musique, réglages (volumes, taille du texte, animations), tableau de progrès pour l'adulte, trois types d'exercices (choix multiple, correction de phrase, dictée), CE1 en deux tableaux (accords dans le groupe nominal ; sons et lettres) et CE2 « homophones grammaticaux » (a/à, et/est, son/sont, on/ont), 30 missions ; CM1 et CM2 sont prévus. Contenu pédagogique entièrement en brouillon (`draft`, à valider) et voix des dictées provisoires (voir `assets/CREDITS.md` ; voir `docs/specs/decisions.md` pour les décisions de cadrage et `docs/specs/cahier-des-charges.md` pour la spécification complète).
## Pile technique
diff --git a/assets/audio/dictations/ce1_cedille_dictee_01.ogg b/assets/audio/dictations/ce1_cedille_dictee_01.ogg
new file mode 100644
index 0000000..77233a3
Binary files /dev/null and b/assets/audio/dictations/ce1_cedille_dictee_01.ogg differ
diff --git a/assets/audio/dictations/ce1_cedille_dictee_01.ogg.import b/assets/audio/dictations/ce1_cedille_dictee_01.ogg.import
new file mode 100644
index 0000000..cc79939
--- /dev/null
+++ b/assets/audio/dictations/ce1_cedille_dictee_01.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://bdm0vxnxxkw54"
+path="res://.godot/imported/ce1_cedille_dictee_01.ogg-e6552e5f28eccfaef4b40e84445125a8.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/dictations/ce1_cedille_dictee_01.ogg"
+dest_files=["res://.godot/imported/ce1_cedille_dictee_01.ogg-e6552e5f28eccfaef4b40e84445125a8.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/assets/audio/dictations/ce1_cedille_dictee_02.ogg b/assets/audio/dictations/ce1_cedille_dictee_02.ogg
new file mode 100644
index 0000000..a56a643
Binary files /dev/null and b/assets/audio/dictations/ce1_cedille_dictee_02.ogg differ
diff --git a/assets/audio/dictations/ce1_cedille_dictee_02.ogg.import b/assets/audio/dictations/ce1_cedille_dictee_02.ogg.import
new file mode 100644
index 0000000..673c59f
--- /dev/null
+++ b/assets/audio/dictations/ce1_cedille_dictee_02.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://dwfv2ipp087np"
+path="res://.godot/imported/ce1_cedille_dictee_02.ogg-73f9911fa2b7714b3f55d92b66623b7e.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/dictations/ce1_cedille_dictee_02.ogg"
+dest_files=["res://.godot/imported/ce1_cedille_dictee_02.ogg-73f9911fa2b7714b3f55d92b66623b7e.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/assets/audio/dictations/ce1_cedille_dictee_03.ogg b/assets/audio/dictations/ce1_cedille_dictee_03.ogg
new file mode 100644
index 0000000..ea83030
Binary files /dev/null and b/assets/audio/dictations/ce1_cedille_dictee_03.ogg differ
diff --git a/assets/audio/dictations/ce1_cedille_dictee_03.ogg.import b/assets/audio/dictations/ce1_cedille_dictee_03.ogg.import
new file mode 100644
index 0000000..60c029d
--- /dev/null
+++ b/assets/audio/dictations/ce1_cedille_dictee_03.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://byjf2bqmuoagv"
+path="res://.godot/imported/ce1_cedille_dictee_03.ogg-2c32435b472153a974b331067c09e665.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/dictations/ce1_cedille_dictee_03.ogg"
+dest_files=["res://.godot/imported/ce1_cedille_dictee_03.ogg-2c32435b472153a974b331067c09e665.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/assets/audio/dictations/ce1_m_mbp_dictee_01.ogg b/assets/audio/dictations/ce1_m_mbp_dictee_01.ogg
new file mode 100644
index 0000000..7d776c4
Binary files /dev/null and b/assets/audio/dictations/ce1_m_mbp_dictee_01.ogg differ
diff --git a/assets/audio/dictations/ce1_m_mbp_dictee_01.ogg.import b/assets/audio/dictations/ce1_m_mbp_dictee_01.ogg.import
new file mode 100644
index 0000000..c814275
--- /dev/null
+++ b/assets/audio/dictations/ce1_m_mbp_dictee_01.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://bi7a01kq1knvw"
+path="res://.godot/imported/ce1_m_mbp_dictee_01.ogg-d2fc4ef0f9901ff6b4afb10ea6a0732d.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/dictations/ce1_m_mbp_dictee_01.ogg"
+dest_files=["res://.godot/imported/ce1_m_mbp_dictee_01.ogg-d2fc4ef0f9901ff6b4afb10ea6a0732d.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/assets/audio/dictations/ce1_m_mbp_dictee_02.ogg b/assets/audio/dictations/ce1_m_mbp_dictee_02.ogg
new file mode 100644
index 0000000..41f4660
Binary files /dev/null and b/assets/audio/dictations/ce1_m_mbp_dictee_02.ogg differ
diff --git a/assets/audio/dictations/ce1_m_mbp_dictee_02.ogg.import b/assets/audio/dictations/ce1_m_mbp_dictee_02.ogg.import
new file mode 100644
index 0000000..227234e
--- /dev/null
+++ b/assets/audio/dictations/ce1_m_mbp_dictee_02.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://c62jcqms3ukxy"
+path="res://.godot/imported/ce1_m_mbp_dictee_02.ogg-f8e113eb7b73b6f437637b57c7a4d46b.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/dictations/ce1_m_mbp_dictee_02.ogg"
+dest_files=["res://.godot/imported/ce1_m_mbp_dictee_02.ogg-f8e113eb7b73b6f437637b57c7a4d46b.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/assets/audio/dictations/ce1_m_mbp_dictee_03.ogg b/assets/audio/dictations/ce1_m_mbp_dictee_03.ogg
new file mode 100644
index 0000000..040005c
Binary files /dev/null and b/assets/audio/dictations/ce1_m_mbp_dictee_03.ogg differ
diff --git a/assets/audio/dictations/ce1_m_mbp_dictee_03.ogg.import b/assets/audio/dictations/ce1_m_mbp_dictee_03.ogg.import
new file mode 100644
index 0000000..0d79882
--- /dev/null
+++ b/assets/audio/dictations/ce1_m_mbp_dictee_03.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://c78pgyq4b1x6b"
+path="res://.godot/imported/ce1_m_mbp_dictee_03.ogg-2126902c8207abfcba6a006d9f1e10a5.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/dictations/ce1_m_mbp_dictee_03.ogg"
+dest_files=["res://.godot/imported/ce1_m_mbp_dictee_03.ogg-2126902c8207abfcba6a006d9f1e10a5.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/assets/audio/dictations/ce1_s_ss_dictee_01.ogg b/assets/audio/dictations/ce1_s_ss_dictee_01.ogg
new file mode 100644
index 0000000..3d4e0aa
Binary files /dev/null and b/assets/audio/dictations/ce1_s_ss_dictee_01.ogg differ
diff --git a/assets/audio/dictations/ce1_s_ss_dictee_01.ogg.import b/assets/audio/dictations/ce1_s_ss_dictee_01.ogg.import
new file mode 100644
index 0000000..62c40bc
--- /dev/null
+++ b/assets/audio/dictations/ce1_s_ss_dictee_01.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://cx7m1ctlcqrov"
+path="res://.godot/imported/ce1_s_ss_dictee_01.ogg-8fad4cd28cdf9a8f5365d3d14e9e5b63.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/dictations/ce1_s_ss_dictee_01.ogg"
+dest_files=["res://.godot/imported/ce1_s_ss_dictee_01.ogg-8fad4cd28cdf9a8f5365d3d14e9e5b63.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/assets/audio/dictations/ce1_s_ss_dictee_02.ogg b/assets/audio/dictations/ce1_s_ss_dictee_02.ogg
new file mode 100644
index 0000000..4ea0e38
Binary files /dev/null and b/assets/audio/dictations/ce1_s_ss_dictee_02.ogg differ
diff --git a/assets/audio/dictations/ce1_s_ss_dictee_02.ogg.import b/assets/audio/dictations/ce1_s_ss_dictee_02.ogg.import
new file mode 100644
index 0000000..d618dd5
--- /dev/null
+++ b/assets/audio/dictations/ce1_s_ss_dictee_02.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://ce6adfsld1w1f"
+path="res://.godot/imported/ce1_s_ss_dictee_02.ogg-768c28f2e08978c61c4d60bbfa125412.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/dictations/ce1_s_ss_dictee_02.ogg"
+dest_files=["res://.godot/imported/ce1_s_ss_dictee_02.ogg-768c28f2e08978c61c4d60bbfa125412.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/assets/audio/dictations/ce1_s_ss_dictee_03.ogg b/assets/audio/dictations/ce1_s_ss_dictee_03.ogg
new file mode 100644
index 0000000..cab6724
Binary files /dev/null and b/assets/audio/dictations/ce1_s_ss_dictee_03.ogg differ
diff --git a/assets/audio/dictations/ce1_s_ss_dictee_03.ogg.import b/assets/audio/dictations/ce1_s_ss_dictee_03.ogg.import
new file mode 100644
index 0000000..d039c91
--- /dev/null
+++ b/assets/audio/dictations/ce1_s_ss_dictee_03.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://cgmru2lpwpprh"
+path="res://.godot/imported/ce1_s_ss_dictee_03.ogg-2d8ec2781067dfd6a5691f3ff427c449.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/dictations/ce1_s_ss_dictee_03.ogg"
+dest_files=["res://.godot/imported/ce1_s_ss_dictee_03.ogg-2d8ec2781067dfd6a5691f3ff427c449.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/assets/environments/beach_backdrop.svg b/assets/environments/beach_backdrop.svg
new file mode 100644
index 0000000..36cd12f
--- /dev/null
+++ b/assets/environments/beach_backdrop.svg
@@ -0,0 +1,59 @@
+
\ No newline at end of file
diff --git a/assets/environments/beach_backdrop.svg.import b/assets/environments/beach_backdrop.svg.import
new file mode 100644
index 0000000..5164adc
--- /dev/null
+++ b/assets/environments/beach_backdrop.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ctpna4eidmvd1"
+path="res://.godot/imported/beach_backdrop.svg-e8f55deddf7256ec7aef29764778e1d0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/environments/beach_backdrop.svg"
+dest_files=["res://.godot/imported/beach_backdrop.svg-e8f55deddf7256ec7aef29764778e1d0.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/autoload/audio_manager.gd b/autoload/audio_manager.gd
index b253709..ff432d8 100644
--- a/autoload/audio_manager.gd
+++ b/autoload/audio_manager.gd
@@ -12,6 +12,7 @@ var _voice_player: AudioStreamPlayer
var _music_player: AudioStreamPlayer
var _effect_players: Array[AudioStreamPlayer] = []
var _current_music_id := ""
+var _backdrop_kind := "forest"
var _stream_cache: Dictionary = {}
@@ -76,10 +77,16 @@ func play_music(music_id: String) -> void:
_music_player.play()
-## Musique de fond selon le choix des reglages et le niveau scolaire de la
-## scene appelante (« automatique » = celle de la region).
-func play_region_music(level: String) -> void:
- play_music(SoundCatalog.resolve_music(SettingsManager.music_track, level))
+## Musique de fond selon le choix des reglages et le decor du tableau de la
+## scene appelante (« automatique » = celle du decor).
+func play_region_music(backdrop_kind: String) -> void:
+ _backdrop_kind = backdrop_kind
+ play_music(SoundCatalog.resolve_music(SettingsManager.music_track, backdrop_kind))
+
+
+## Decor du dernier tableau annonce a play_region_music (« forest » au debut).
+func current_backdrop_kind() -> String:
+ return _backdrop_kind
func current_music_id() -> String:
diff --git a/autoload/exercise_engine.gd b/autoload/exercise_engine.gd
index 94dc998..daf0f5d 100644
--- a/autoload/exercise_engine.gd
+++ b/autoload/exercise_engine.gd
@@ -11,6 +11,8 @@ 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()
@@ -19,6 +21,7 @@ var _rng := RandomNumberGenerator.new()
func _ready() -> void:
_rng.randomize()
_load_rules()
+ _load_regions()
_load_exercises()
_load_dictations()
_load_missions()
@@ -46,6 +49,47 @@ func get_missions_for_level(level: String) -> Array[Dictionary]:
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]:
@@ -113,6 +157,32 @@ func _load_rules() -> void:
_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)
diff --git a/autoload/game_manager.gd b/autoload/game_manager.gd
index 3c9a321..e9d9363 100644
--- a/autoload/game_manager.gd
+++ b/autoload/game_manager.gd
@@ -27,6 +27,9 @@ var last_outcomes: Array[String] = []
## profil, selon d'ou il a ete ouvert).
var settings_return_scene: String = SCENE_WORLD_MAP
+## Dernier tableau consulte sur la carte : on y revient apres une mission.
+var map_region_id: String = ""
+
func goto(scene_path: String) -> void:
# Differe le changement de scene : l'appeler depuis le _ready() de la
diff --git a/data/curriculum/ce1_accords_groupe_nominal.json b/data/curriculum/ce1_accords_groupe_nominal.json
index f875502..690bc61 100644
--- a/data/curriculum/ce1_accords_groupe_nominal.json
+++ b/data/curriculum/ce1_accords_groupe_nominal.json
@@ -3,6 +3,13 @@
"region_id": "foret_des_accords",
"chapter_id": "accords_groupe_nominal",
"title": "Les accords dans le groupe nominal",
- "rule_ids": ["regle_accord_adjectif", "regle_pluriel_nom", "regle_accord_determinant"],
+ "region_title": "La forêt des accords",
+ "order": 1,
+ "backdrop": "forest",
+ "rule_ids": [
+ "regle_accord_adjectif",
+ "regle_pluriel_nom",
+ "regle_accord_determinant"
+ ],
"review_status": "draft"
}
diff --git a/data/curriculum/ce1_sons_et_lettres.json b/data/curriculum/ce1_sons_et_lettres.json
new file mode 100644
index 0000000..b20ba97
--- /dev/null
+++ b/data/curriculum/ce1_sons_et_lettres.json
@@ -0,0 +1,15 @@
+{
+ "level": "CE1",
+ "region_id": "plage_des_sons",
+ "chapter_id": "sons_et_lettres",
+ "title": "Les sons et les lettres",
+ "region_title": "La plage des sons",
+ "order": 2,
+ "backdrop": "beach",
+ "rule_ids": [
+ "regle_s_ss",
+ "regle_c_cedille",
+ "regle_m_devant_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/curriculum/ce2_homophones_grammaticaux.json b/data/curriculum/ce2_homophones_grammaticaux.json
index 726a165..0af7378 100644
--- a/data/curriculum/ce2_homophones_grammaticaux.json
+++ b/data/curriculum/ce2_homophones_grammaticaux.json
@@ -3,6 +3,9 @@
"region_id": "marais_des_homophones",
"chapter_id": "homophones_grammaticaux",
"title": "Les homophones grammaticaux",
+ "region_title": "Le marais des homophones",
+ "order": 1,
+ "backdrop": "marsh",
"rule_ids": [
"regle_homophone_a_a",
"regle_homophone_et_est",
diff --git a/data/dictations/ce1_cedille_dictee_01.json b/data/dictations/ce1_cedille_dictee_01.json
new file mode 100644
index 0000000..e71d265
--- /dev/null
+++ b/data/dictations/ce1_cedille_dictee_01.json
@@ -0,0 +1,37 @@
+{
+ "id": "ce1_cedille_dictee_01",
+ "level": "CE1",
+ "domain": "orthographe",
+ "rule_ids": [
+ "regle_c_cedille"
+ ],
+ "difficulty": 1,
+ "target_text": "Ma leçon de français est facile.",
+ "words": [
+ {
+ "text": "Ma",
+ "expects_capital": true
+ },
+ {
+ "text": "leçon",
+ "rule_id": "regle_c_cedille"
+ },
+ {
+ "text": "de"
+ },
+ {
+ "text": "français",
+ "rule_id": "regle_c_cedille"
+ },
+ {
+ "text": "est"
+ },
+ {
+ "text": "facile."
+ }
+ ],
+ "audio_id": "ce1_cedille_dictee_01",
+ "audio_provisional": true,
+ "accepted_variants": [],
+ "review_status": "draft"
+}
diff --git a/data/dictations/ce1_cedille_dictee_02.json b/data/dictations/ce1_cedille_dictee_02.json
new file mode 100644
index 0000000..aa20819
--- /dev/null
+++ b/data/dictations/ce1_cedille_dictee_02.json
@@ -0,0 +1,37 @@
+{
+ "id": "ce1_cedille_dictee_02",
+ "level": "CE1",
+ "domain": "orthographe",
+ "rule_ids": [
+ "regle_c_cedille"
+ ],
+ "difficulty": 2,
+ "target_text": "Le maçon construit une façade neuve.",
+ "words": [
+ {
+ "text": "Le",
+ "expects_capital": true
+ },
+ {
+ "text": "maçon",
+ "rule_id": "regle_c_cedille"
+ },
+ {
+ "text": "construit"
+ },
+ {
+ "text": "une"
+ },
+ {
+ "text": "façade",
+ "rule_id": "regle_c_cedille"
+ },
+ {
+ "text": "neuve."
+ }
+ ],
+ "audio_id": "ce1_cedille_dictee_02",
+ "audio_provisional": true,
+ "accepted_variants": [],
+ "review_status": "draft"
+}
diff --git a/data/dictations/ce1_cedille_dictee_03.json b/data/dictations/ce1_cedille_dictee_03.json
new file mode 100644
index 0000000..c02f9d3
--- /dev/null
+++ b/data/dictations/ce1_cedille_dictee_03.json
@@ -0,0 +1,42 @@
+{
+ "id": "ce1_cedille_dictee_03",
+ "level": "CE1",
+ "domain": "orthographe",
+ "rule_ids": [
+ "regle_c_cedille"
+ ],
+ "difficulty": 3,
+ "target_text": "Hier, j'ai reçu une carte postale de Lola.",
+ "words": [
+ {
+ "text": "Hier,",
+ "expects_capital": true
+ },
+ {
+ "text": "j'ai"
+ },
+ {
+ "text": "reçu",
+ "rule_id": "regle_c_cedille"
+ },
+ {
+ "text": "une"
+ },
+ {
+ "text": "carte"
+ },
+ {
+ "text": "postale"
+ },
+ {
+ "text": "de"
+ },
+ {
+ "text": "Lola."
+ }
+ ],
+ "audio_id": "ce1_cedille_dictee_03",
+ "audio_provisional": true,
+ "accepted_variants": [],
+ "review_status": "draft"
+}
diff --git a/data/dictations/ce1_m_mbp_dictee_01.json b/data/dictations/ce1_m_mbp_dictee_01.json
new file mode 100644
index 0000000..08c71ba
--- /dev/null
+++ b/data/dictations/ce1_m_mbp_dictee_01.json
@@ -0,0 +1,39 @@
+{
+ "id": "ce1_m_mbp_dictee_01",
+ "level": "CE1",
+ "domain": "orthographe",
+ "rule_ids": [
+ "regle_m_devant_mbp"
+ ],
+ "difficulty": 1,
+ "target_text": "Le pompier monte sur la grande échelle.",
+ "words": [
+ {
+ "text": "Le",
+ "expects_capital": true
+ },
+ {
+ "text": "pompier",
+ "rule_id": "regle_m_devant_mbp"
+ },
+ {
+ "text": "monte"
+ },
+ {
+ "text": "sur"
+ },
+ {
+ "text": "la"
+ },
+ {
+ "text": "grande"
+ },
+ {
+ "text": "échelle."
+ }
+ ],
+ "audio_id": "ce1_m_mbp_dictee_01",
+ "audio_provisional": true,
+ "accepted_variants": [],
+ "review_status": "draft"
+}
diff --git a/data/dictations/ce1_m_mbp_dictee_02.json b/data/dictations/ce1_m_mbp_dictee_02.json
new file mode 100644
index 0000000..ff7c165
--- /dev/null
+++ b/data/dictations/ce1_m_mbp_dictee_02.json
@@ -0,0 +1,45 @@
+{
+ "id": "ce1_m_mbp_dictee_02",
+ "level": "CE1",
+ "domain": "orthographe",
+ "rule_ids": [
+ "regle_m_devant_mbp"
+ ],
+ "difficulty": 2,
+ "target_text": "Il y a un timbre rouge sur la lettre.",
+ "words": [
+ {
+ "text": "Il",
+ "expects_capital": true
+ },
+ {
+ "text": "y"
+ },
+ {
+ "text": "a"
+ },
+ {
+ "text": "un"
+ },
+ {
+ "text": "timbre",
+ "rule_id": "regle_m_devant_mbp"
+ },
+ {
+ "text": "rouge"
+ },
+ {
+ "text": "sur"
+ },
+ {
+ "text": "la"
+ },
+ {
+ "text": "lettre."
+ }
+ ],
+ "audio_id": "ce1_m_mbp_dictee_02",
+ "audio_provisional": true,
+ "accepted_variants": [],
+ "review_status": "draft"
+}
diff --git a/data/dictations/ce1_m_mbp_dictee_03.json b/data/dictations/ce1_m_mbp_dictee_03.json
new file mode 100644
index 0000000..907f4e0
--- /dev/null
+++ b/data/dictations/ce1_m_mbp_dictee_03.json
@@ -0,0 +1,46 @@
+{
+ "id": "ce1_m_mbp_dictee_03",
+ "level": "CE1",
+ "domain": "orthographe",
+ "rule_ids": [
+ "regle_m_devant_mbp"
+ ],
+ "difficulty": 3,
+ "target_text": "Le campeur plante sa tente près de la rivière.",
+ "words": [
+ {
+ "text": "Le",
+ "expects_capital": true
+ },
+ {
+ "text": "campeur",
+ "rule_id": "regle_m_devant_mbp"
+ },
+ {
+ "text": "plante"
+ },
+ {
+ "text": "sa"
+ },
+ {
+ "text": "tente",
+ "rule_id": "regle_m_devant_mbp"
+ },
+ {
+ "text": "près"
+ },
+ {
+ "text": "de"
+ },
+ {
+ "text": "la"
+ },
+ {
+ "text": "rivière."
+ }
+ ],
+ "audio_id": "ce1_m_mbp_dictee_03",
+ "audio_provisional": true,
+ "accepted_variants": [],
+ "review_status": "draft"
+}
diff --git a/data/dictations/ce1_s_ss_dictee_01.json b/data/dictations/ce1_s_ss_dictee_01.json
new file mode 100644
index 0000000..cb6a885
--- /dev/null
+++ b/data/dictations/ce1_s_ss_dictee_01.json
@@ -0,0 +1,39 @@
+{
+ "id": "ce1_s_ss_dictee_01",
+ "level": "CE1",
+ "domain": "orthographe",
+ "rule_ids": [
+ "regle_s_ss"
+ ],
+ "difficulty": 1,
+ "target_text": "Le poisson rouge nage dans le bocal.",
+ "words": [
+ {
+ "text": "Le",
+ "expects_capital": true
+ },
+ {
+ "text": "poisson",
+ "rule_id": "regle_s_ss"
+ },
+ {
+ "text": "rouge"
+ },
+ {
+ "text": "nage"
+ },
+ {
+ "text": "dans"
+ },
+ {
+ "text": "le"
+ },
+ {
+ "text": "bocal."
+ }
+ ],
+ "audio_id": "ce1_s_ss_dictee_01",
+ "audio_provisional": true,
+ "accepted_variants": [],
+ "review_status": "draft"
+}
diff --git a/data/dictations/ce1_s_ss_dictee_02.json b/data/dictations/ce1_s_ss_dictee_02.json
new file mode 100644
index 0000000..4403e49
--- /dev/null
+++ b/data/dictations/ce1_s_ss_dictee_02.json
@@ -0,0 +1,42 @@
+{
+ "id": "ce1_s_ss_dictee_02",
+ "level": "CE1",
+ "domain": "orthographe",
+ "rule_ids": [
+ "regle_s_ss"
+ ],
+ "difficulty": 2,
+ "target_text": "La grosse tasse est pleine de chocolat chaud.",
+ "words": [
+ {
+ "text": "La",
+ "expects_capital": true
+ },
+ {
+ "text": "grosse"
+ },
+ {
+ "text": "tasse",
+ "rule_id": "regle_s_ss"
+ },
+ {
+ "text": "est"
+ },
+ {
+ "text": "pleine"
+ },
+ {
+ "text": "de"
+ },
+ {
+ "text": "chocolat"
+ },
+ {
+ "text": "chaud."
+ }
+ ],
+ "audio_id": "ce1_s_ss_dictee_02",
+ "audio_provisional": true,
+ "accepted_variants": [],
+ "review_status": "draft"
+}
diff --git a/data/dictations/ce1_s_ss_dictee_03.json b/data/dictations/ce1_s_ss_dictee_03.json
new file mode 100644
index 0000000..b97968c
--- /dev/null
+++ b/data/dictations/ce1_s_ss_dictee_03.json
@@ -0,0 +1,42 @@
+{
+ "id": "ce1_s_ss_dictee_03",
+ "level": "CE1",
+ "domain": "orthographe",
+ "rule_ids": [
+ "regle_s_ss"
+ ],
+ "difficulty": 3,
+ "target_text": "Mamie a mis ses chaussons dans la chambre.",
+ "words": [
+ {
+ "text": "Mamie",
+ "expects_capital": true
+ },
+ {
+ "text": "a"
+ },
+ {
+ "text": "mis"
+ },
+ {
+ "text": "ses"
+ },
+ {
+ "text": "chaussons",
+ "rule_id": "regle_s_ss"
+ },
+ {
+ "text": "dans"
+ },
+ {
+ "text": "la"
+ },
+ {
+ "text": "chambre."
+ }
+ ],
+ "audio_id": "ce1_s_ss_dictee_03",
+ "audio_provisional": true,
+ "accepted_variants": [],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_correction_01.json b/data/exercises/ce1/ce1_cedille_correction_01.json
new file mode 100644
index 0000000..f3144c8
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_correction_01.json
@@ -0,0 +1,53 @@
+{
+ "id": "ce1_cedille_correction_01",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 1,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Léo",
+ "correct": true
+ },
+ {
+ "text": "se",
+ "correct": true
+ },
+ {
+ "text": "balance",
+ "correct": true
+ },
+ {
+ "text": "sur",
+ "correct": true
+ },
+ {
+ "text": "la",
+ "correct": true
+ },
+ {
+ "text": "balancoire",
+ "correct": false,
+ "expected": "balançoire",
+ "rule_id": "regle_c_cedille"
+ },
+ {
+ "text": "du",
+ "correct": true
+ },
+ {
+ "text": "parc.",
+ "correct": true
+ }
+ ],
+ "hint": "Devant « o », pour entendre [s], il faut une cédille.",
+ "explanation": "On écrit « ç » devant « o » pour le son [s] : « balançoire ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_correction_02.json b/data/exercises/ce1/ce1_cedille_correction_02.json
new file mode 100644
index 0000000..36dcad0
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_correction_02.json
@@ -0,0 +1,57 @@
+{
+ "id": "ce1_cedille_correction_02",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 2,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Il",
+ "correct": true
+ },
+ {
+ "text": "y",
+ "correct": true
+ },
+ {
+ "text": "a",
+ "correct": true
+ },
+ {
+ "text": "un",
+ "correct": true
+ },
+ {
+ "text": "glacon",
+ "correct": false,
+ "expected": "glaçon",
+ "rule_id": "regle_c_cedille"
+ },
+ {
+ "text": "dans",
+ "correct": true
+ },
+ {
+ "text": "mon",
+ "correct": true
+ },
+ {
+ "text": "verre",
+ "correct": true
+ },
+ {
+ "text": "d'eau.",
+ "correct": true
+ }
+ ],
+ "hint": "Devant « o », le son [s] demande une cédille.",
+ "explanation": "On écrit « ç » devant « o » pour le son [s] : « glaçon ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_correction_03.json b/data/exercises/ce1/ce1_cedille_correction_03.json
new file mode 100644
index 0000000..26af277
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_correction_03.json
@@ -0,0 +1,49 @@
+{
+ "id": "ce1_cedille_correction_03",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 2,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Nous",
+ "correct": true
+ },
+ {
+ "text": "commencons",
+ "correct": false,
+ "expected": "commençons",
+ "rule_id": "regle_c_cedille"
+ },
+ {
+ "text": "la",
+ "correct": true
+ },
+ {
+ "text": "dictée",
+ "correct": true
+ },
+ {
+ "text": "à",
+ "correct": true
+ },
+ {
+ "text": "neuf",
+ "correct": true
+ },
+ {
+ "text": "heures.",
+ "correct": true
+ }
+ ],
+ "hint": "Devant « o », le son [s] demande une cédille.",
+ "explanation": "On écrit « ç » devant « o » pour le son [s] : « commençons ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_correction_04.json b/data/exercises/ce1/ce1_cedille_correction_04.json
new file mode 100644
index 0000000..e029775
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_correction_04.json
@@ -0,0 +1,53 @@
+{
+ "id": "ce1_cedille_correction_04",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 3,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "La",
+ "correct": true
+ },
+ {
+ "text": "maman",
+ "correct": true
+ },
+ {
+ "text": "appelle",
+ "correct": true
+ },
+ {
+ "text": "son",
+ "correct": true
+ },
+ {
+ "text": "garcon",
+ "correct": false,
+ "expected": "garçon",
+ "rule_id": "regle_c_cedille"
+ },
+ {
+ "text": "pour",
+ "correct": true
+ },
+ {
+ "text": "le",
+ "correct": true
+ },
+ {
+ "text": "dîner.",
+ "correct": true
+ }
+ ],
+ "hint": "Devant « o », pour entendre [s], il faut une cédille.",
+ "explanation": "On écrit « ç » devant « o » pour le son [s] : « garçon ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_qcm_01.json b/data/exercises/ce1/ce1_cedille_qcm_01.json
new file mode 100644
index 0000000..ddc97bc
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_qcm_01.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_cedille_qcm_01",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 1,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Le petit ... joue au ballon.",
+ "answers": [
+ "garçon",
+ "garcon"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « o », pour entendre [s], il faut une cédille.",
+ "explanation": "Devant « o », « c » se lit [k] ; pour entendre [s], on écrit « ç » : « garçon ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_qcm_02.json b/data/exercises/ce1/ce1_cedille_qcm_02.json
new file mode 100644
index 0000000..9c3a0d0
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_qcm_02.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_cedille_qcm_02",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 1,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Ma maîtresse nous donne une ... à apprendre.",
+ "answers": [
+ "leçon",
+ "lecon"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « o », pour entendre [s], il faut une cédille.",
+ "explanation": "On écrit « ç » devant « o » pour le son [s] : « leçon ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_qcm_03.json b/data/exercises/ce1/ce1_cedille_qcm_03.json
new file mode 100644
index 0000000..b5bf2c0
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_qcm_03.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_cedille_qcm_03",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 1,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "... va très bien, merci !",
+ "answers": [
+ "Ça",
+ "Ca"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « a », pour entendre [s], il faut une cédille.",
+ "explanation": "On écrit « ç » devant « a » pour le son [s] : « ça ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_qcm_04.json b/data/exercises/ce1/ce1_cedille_qcm_04.json
new file mode 100644
index 0000000..2c04713
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_qcm_04.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_cedille_qcm_04",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 2,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Le ... construit un mur en pierre.",
+ "answers": [
+ "maçon",
+ "macon"
+ ],
+ "correct_answer": 0,
+ "hint": "Le son [s] devant « o » s'écrit avec une cédille.",
+ "explanation": "On écrit « ç » devant « o » pour le son [s] : « maçon ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_qcm_05.json b/data/exercises/ce1/ce1_cedille_qcm_05.json
new file mode 100644
index 0000000..43b8156
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_qcm_05.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_cedille_qcm_05",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 2,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "La ... de la maison est blanche.",
+ "answers": [
+ "façade",
+ "facade"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « a », pour entendre [s], il faut une cédille.",
+ "explanation": "On écrit « ç » devant « a » pour le son [s] : « façade ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_qcm_06.json b/data/exercises/ce1/ce1_cedille_qcm_06.json
new file mode 100644
index 0000000..9c59bef
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_qcm_06.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_cedille_qcm_06",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 2,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Mon livre est écrit en ... .",
+ "answers": [
+ "français",
+ "francais"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « a », le son [s] demande une cédille.",
+ "explanation": "On écrit « ç » devant « a » pour le son [s] : « français ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_qcm_07.json b/data/exercises/ce1/ce1_cedille_qcm_07.json
new file mode 100644
index 0000000..24d0ceb
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_qcm_07.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_cedille_qcm_07",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 3,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Papa a ... une lettre ce matin.",
+ "answers": [
+ "reçu",
+ "recu"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « u », pour entendre [s], il faut une cédille.",
+ "explanation": "On écrit « ç » devant « u » pour le son [s] : « reçu ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_cedille_qcm_08.json b/data/exercises/ce1/ce1_cedille_qcm_08.json
new file mode 100644
index 0000000..42e3138
--- /dev/null
+++ b/data/exercises/ce1/ce1_cedille_qcm_08.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_cedille_qcm_08",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_c_cedille",
+ "difficulty": 3,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Les enfants forment un grand ... dans la cour.",
+ "answers": [
+ "cercle",
+ "çercle"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « e », la lettre « c » donne déjà le son [s].",
+ "explanation": "Devant « e » et « i », « c » se lit [s] tout seul : pas de cédille, on écrit « cercle ».",
+ "review_tags": [
+ "sons_lettres",
+ "cedille"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_correction_01.json b/data/exercises/ce1/ce1_m_mbp_correction_01.json
new file mode 100644
index 0000000..b4cb250
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_correction_01.json
@@ -0,0 +1,49 @@
+{
+ "id": "ce1_m_mbp_correction_01",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 1,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Maman",
+ "correct": true
+ },
+ {
+ "text": "coupe",
+ "correct": true
+ },
+ {
+ "text": "du",
+ "correct": true
+ },
+ {
+ "text": "janbon",
+ "correct": false,
+ "expected": "jambon",
+ "rule_id": "regle_m_devant_mbp"
+ },
+ {
+ "text": "pour",
+ "correct": true
+ },
+ {
+ "text": "le",
+ "correct": true
+ },
+ {
+ "text": "pique-nique.",
+ "correct": true
+ }
+ ],
+ "hint": "Regarde la lettre qui suit : un « b ».",
+ "explanation": "Devant « b », on écrit « m » : « jambon ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_correction_02.json b/data/exercises/ce1/ce1_m_mbp_correction_02.json
new file mode 100644
index 0000000..52bd577
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_correction_02.json
@@ -0,0 +1,45 @@
+{
+ "id": "ce1_m_mbp_correction_02",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 2,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Le",
+ "correct": true
+ },
+ {
+ "text": "clown",
+ "correct": true
+ },
+ {
+ "text": "souffle",
+ "correct": true
+ },
+ {
+ "text": "dans",
+ "correct": true
+ },
+ {
+ "text": "sa",
+ "correct": true
+ },
+ {
+ "text": "tronpette.",
+ "correct": false,
+ "expected": "trompette.",
+ "rule_id": "regle_m_devant_mbp"
+ }
+ ],
+ "hint": "Devant « p », quelle lettre faut-il écrire ?",
+ "explanation": "Devant « p », on écrit « m » : « trompette ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_correction_03.json b/data/exercises/ce1/ce1_m_mbp_correction_03.json
new file mode 100644
index 0000000..5a0a823
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_correction_03.json
@@ -0,0 +1,49 @@
+{
+ "id": "ce1_m_mbp_correction_03",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 2,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Il",
+ "correct": true
+ },
+ {
+ "text": "faut",
+ "correct": true
+ },
+ {
+ "text": "changer",
+ "correct": true
+ },
+ {
+ "text": "l'anpoule",
+ "correct": false,
+ "expected": "l'ampoule",
+ "rule_id": "regle_m_devant_mbp"
+ },
+ {
+ "text": "de",
+ "correct": true
+ },
+ {
+ "text": "la",
+ "correct": true
+ },
+ {
+ "text": "cuisine.",
+ "correct": true
+ }
+ ],
+ "hint": "Devant « p », quelle lettre faut-il écrire ?",
+ "explanation": "Devant « p », on écrit « m » : « ampoule ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_correction_04.json b/data/exercises/ce1/ce1_m_mbp_correction_04.json
new file mode 100644
index 0000000..c3bc534
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_correction_04.json
@@ -0,0 +1,53 @@
+{
+ "id": "ce1_m_mbp_correction_04",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 3,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Lola",
+ "correct": true
+ },
+ {
+ "text": "va",
+ "correct": true
+ },
+ {
+ "text": "enmener",
+ "correct": false,
+ "expected": "emmener",
+ "rule_id": "regle_m_devant_mbp"
+ },
+ {
+ "text": "son",
+ "correct": true
+ },
+ {
+ "text": "chien",
+ "correct": true
+ },
+ {
+ "text": "chez",
+ "correct": true
+ },
+ {
+ "text": "le",
+ "correct": true
+ },
+ {
+ "text": "vétérinaire.",
+ "correct": true
+ }
+ ],
+ "hint": "Devant un « m », on écrit aussi « m ».",
+ "explanation": "Devant « m », on écrit « m » : « emmener » (deux m).",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_qcm_01.json b/data/exercises/ce1/ce1_m_mbp_qcm_01.json
new file mode 100644
index 0000000..a4142c8
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_qcm_01.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_m_mbp_qcm_01",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 1,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Papa allume la ... près du lit.",
+ "answers": [
+ "lampe",
+ "lanpe"
+ ],
+ "correct_answer": 0,
+ "hint": "Après la voyelle, on entend [p] : quelle lettre faut-il avant ?",
+ "explanation": "Devant « p », on écrit « m » : « lampe ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_qcm_02.json b/data/exercises/ce1/ce1_m_mbp_qcm_02.json
new file mode 100644
index 0000000..3e3a53f
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_qcm_02.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_m_mbp_qcm_02",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 1,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Le garçon a mal à la ... .",
+ "answers": [
+ "jambe",
+ "janbe"
+ ],
+ "correct_answer": 0,
+ "hint": "Regarde la lettre qui suit : un « b ».",
+ "explanation": "Devant « b », on écrit « m » : « jambe ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_qcm_03.json b/data/exercises/ce1/ce1_m_mbp_qcm_03.json
new file mode 100644
index 0000000..ee3e5dc
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_qcm_03.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_m_mbp_qcm_03",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 1,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Je colle un ... sur l'enveloppe.",
+ "answers": [
+ "timbre",
+ "tinbre"
+ ],
+ "correct_answer": 0,
+ "hint": "Regarde la lettre qui suit : un « b ».",
+ "explanation": "Devant « b », on écrit « m » : « timbre ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_qcm_04.json b/data/exercises/ce1/ce1_m_mbp_qcm_04.json
new file mode 100644
index 0000000..26c6c6f
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_qcm_04.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_m_mbp_qcm_04",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 2,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Le ... éteint l'incendie.",
+ "answers": [
+ "pompier",
+ "ponpier"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « p », quelle lettre faut-il écrire ?",
+ "explanation": "Devant « p », on écrit « m » : « pompier ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_qcm_05.json b/data/exercises/ce1/ce1_m_mbp_qcm_05.json
new file mode 100644
index 0000000..b3ed17f
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_qcm_05.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_m_mbp_qcm_05",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 2,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Il joue du ... dans la fanfare.",
+ "answers": [
+ "tambour",
+ "tanbour"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « b », quelle lettre faut-il écrire ?",
+ "explanation": "Devant « b », on écrit « m » : « tambour ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_qcm_06.json b/data/exercises/ce1/ce1_m_mbp_qcm_06.json
new file mode 100644
index 0000000..2bfba71
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_qcm_06.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_m_mbp_qcm_06",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 2,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Nous dormons sous une ... dans le camping.",
+ "answers": [
+ "tente",
+ "temte"
+ ],
+ "correct_answer": 0,
+ "hint": "Attention : la lettre qui suit n'est ni « m », ni « b », ni « p ».",
+ "explanation": "Devant « t », on écrit « n » : « tente ». La règle du « m » ne vaut que devant m, b, p.",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_qcm_07.json b/data/exercises/ce1/ce1_m_mbp_qcm_07.json
new file mode 100644
index 0000000..869df0c
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_qcm_07.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_m_mbp_qcm_07",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 3,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Ma ... est au premier étage.",
+ "answers": [
+ "chambre",
+ "chanbre"
+ ],
+ "correct_answer": 0,
+ "hint": "Regarde la lettre qui suit : un « b ».",
+ "explanation": "Devant « b », on écrit « m » : « chambre ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_m_mbp_qcm_08.json b/data/exercises/ce1/ce1_m_mbp_qcm_08.json
new file mode 100644
index 0000000..abea06c
--- /dev/null
+++ b/data/exercises/ce1/ce1_m_mbp_qcm_08.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_m_mbp_qcm_08",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_m_devant_mbp",
+ "difficulty": 3,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Les vaches mangent dans le ... .",
+ "answers": [
+ "champ",
+ "chanp"
+ ],
+ "correct_answer": 0,
+ "hint": "Devant « p », quelle lettre faut-il écrire ?",
+ "explanation": "Devant « p », on écrit « m » : « champ ».",
+ "review_tags": [
+ "sons_lettres",
+ "m_mbp"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_correction_01.json b/data/exercises/ce1/ce1_s_ss_correction_01.json
new file mode 100644
index 0000000..285d2f5
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_correction_01.json
@@ -0,0 +1,45 @@
+{
+ "id": "ce1_s_ss_correction_01",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 1,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "La",
+ "correct": true
+ },
+ {
+ "text": "fillette",
+ "correct": true
+ },
+ {
+ "text": "sent",
+ "correct": true
+ },
+ {
+ "text": "une",
+ "correct": true
+ },
+ {
+ "text": "belle",
+ "correct": true
+ },
+ {
+ "text": "roze.",
+ "correct": false,
+ "expected": "rose.",
+ "rule_id": "regle_s_ss"
+ }
+ ],
+ "hint": "Écoute : tu entends [z], comme dans « maison ».",
+ "explanation": "On entend [z] entre deux voyelles : un seul « s », on écrit « rose ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_correction_02.json b/data/exercises/ce1/ce1_s_ss_correction_02.json
new file mode 100644
index 0000000..5e5abd3
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_correction_02.json
@@ -0,0 +1,49 @@
+{
+ "id": "ce1_s_ss_correction_02",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 2,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Tom",
+ "correct": true
+ },
+ {
+ "text": "pouse",
+ "correct": false,
+ "expected": "pousse",
+ "rule_id": "regle_s_ss"
+ },
+ {
+ "text": "la",
+ "correct": true
+ },
+ {
+ "text": "porte",
+ "correct": true
+ },
+ {
+ "text": "de",
+ "correct": true
+ },
+ {
+ "text": "la",
+ "correct": true
+ },
+ {
+ "text": "cabane.",
+ "correct": true
+ }
+ ],
+ "hint": "Tu entends le son [s] entre deux voyelles.",
+ "explanation": "Pour entendre [s] entre deux voyelles, on écrit « ss » : « pousse ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_correction_03.json b/data/exercises/ce1/ce1_s_ss_correction_03.json
new file mode 100644
index 0000000..92736a5
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_correction_03.json
@@ -0,0 +1,49 @@
+{
+ "id": "ce1_s_ss_correction_03",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 2,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Sara",
+ "correct": true
+ },
+ {
+ "text": "se",
+ "correct": true
+ },
+ {
+ "text": "brose",
+ "correct": false,
+ "expected": "brosse",
+ "rule_id": "regle_s_ss"
+ },
+ {
+ "text": "les",
+ "correct": true
+ },
+ {
+ "text": "dents",
+ "correct": true
+ },
+ {
+ "text": "le",
+ "correct": true
+ },
+ {
+ "text": "soir.",
+ "correct": true
+ }
+ ],
+ "hint": "Tu entends le son [s] entre deux voyelles.",
+ "explanation": "Pour entendre [s] entre deux voyelles, on écrit « ss » : « brosse ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_correction_04.json b/data/exercises/ce1/ce1_s_ss_correction_04.json
new file mode 100644
index 0000000..47bd554
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_correction_04.json
@@ -0,0 +1,53 @@
+{
+ "id": "ce1_s_ss_correction_04",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 3,
+ "type": "sentence_correction",
+ "instruction": "Touche le mot qui contient une erreur, puis corrige-le.",
+ "tokens": [
+ {
+ "text": "Les",
+ "correct": true
+ },
+ {
+ "text": "enfants",
+ "correct": true
+ },
+ {
+ "text": "sont",
+ "correct": true
+ },
+ {
+ "text": "asis",
+ "correct": false,
+ "expected": "assis",
+ "rule_id": "regle_s_ss"
+ },
+ {
+ "text": "autour",
+ "correct": true
+ },
+ {
+ "text": "de",
+ "correct": true
+ },
+ {
+ "text": "la",
+ "correct": true
+ },
+ {
+ "text": "table.",
+ "correct": true
+ }
+ ],
+ "hint": "Écoute le son [s] entre le « a » et le « i ».",
+ "explanation": "Pour entendre [s] entre deux voyelles, on écrit « ss » : « assis ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_qcm_01.json b/data/exercises/ce1/ce1_s_ss_qcm_01.json
new file mode 100644
index 0000000..0558339
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_qcm_01.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_s_ss_qcm_01",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 1,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Le chat boit dans une ... .",
+ "answers": [
+ "tasse",
+ "tase"
+ ],
+ "correct_answer": 0,
+ "hint": "Pour entendre [s] entre deux voyelles, il faut deux s.",
+ "explanation": "Entre deux voyelles, on écrit « ss » pour le son [s] : « tasse ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_qcm_02.json b/data/exercises/ce1/ce1_s_ss_qcm_02.json
new file mode 100644
index 0000000..6b87801
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_qcm_02.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_s_ss_qcm_02",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 1,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Il y a une ... près de la table.",
+ "answers": [
+ "chaise",
+ "chaisse"
+ ],
+ "correct_answer": 0,
+ "hint": "Ici, on entend le son [z], comme dans « rose ».",
+ "explanation": "On entend [z] entre deux voyelles : un seul « s » suffit, on écrit « chaise ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_qcm_03.json b/data/exercises/ce1/ce1_s_ss_qcm_03.json
new file mode 100644
index 0000000..6ab6cab
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_qcm_03.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_s_ss_qcm_03",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 1,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Ma ... est près de l'école.",
+ "answers": [
+ "maison",
+ "maisson"
+ ],
+ "correct_answer": 0,
+ "hint": "Écoute : tu entends [z], pas [s].",
+ "explanation": "On entend [z] entre deux voyelles : un seul « s », on écrit « maison ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_qcm_04.json b/data/exercises/ce1/ce1_s_ss_qcm_04.json
new file mode 100644
index 0000000..4e62017
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_qcm_04.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_s_ss_qcm_04",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 2,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Un ... nage dans l'aquarium.",
+ "answers": [
+ "poisson",
+ "poison"
+ ],
+ "correct_answer": 0,
+ "hint": "Pense à l'animal qui vit dans l'eau, et au son [s].",
+ "explanation": "Pour le son [s] entre deux voyelles, on écrit « ss » : « poisson ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_qcm_05.json b/data/exercises/ce1/ce1_s_ss_qcm_05.json
new file mode 100644
index 0000000..b7a9898
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_qcm_05.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_s_ss_qcm_05",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 2,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Un petit ... chante sur la branche.",
+ "answers": [
+ "oiseau",
+ "oisseau"
+ ],
+ "correct_answer": 0,
+ "hint": "Écoute bien : on entend [z] dans ce mot.",
+ "explanation": "On entend [z] entre deux voyelles : un seul « s », on écrit « oiseau ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_qcm_06.json b/data/exercises/ce1/ce1_s_ss_qcm_06.json
new file mode 100644
index 0000000..dac903f
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_qcm_06.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_s_ss_qcm_06",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 2,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Léa fait un beau ... de son chat.",
+ "answers": [
+ "dessin",
+ "desin"
+ ],
+ "correct_answer": 0,
+ "hint": "Le son [s] entre deux voyelles s'écrit avec deux s.",
+ "explanation": "Pour entendre [s] entre « e » et « i », on écrit « ss » : « dessin ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_qcm_07.json b/data/exercises/ce1/ce1_s_ss_qcm_07.json
new file mode 100644
index 0000000..2985d7f
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_qcm_07.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_s_ss_qcm_07",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 3,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "La maîtresse entre dans la ... .",
+ "answers": [
+ "classe",
+ "clase"
+ ],
+ "correct_answer": 0,
+ "hint": "Entre « a » et « e », quel son entends-tu ?",
+ "explanation": "Pour le son [s] entre deux voyelles, on écrit « ss » : « classe ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/exercises/ce1/ce1_s_ss_qcm_08.json b/data/exercises/ce1/ce1_s_ss_qcm_08.json
new file mode 100644
index 0000000..809896d
--- /dev/null
+++ b/data/exercises/ce1/ce1_s_ss_qcm_08.json
@@ -0,0 +1,23 @@
+{
+ "id": "ce1_s_ss_qcm_08",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "rule_id": "regle_s_ss",
+ "difficulty": 3,
+ "type": "multiple_choice",
+ "instruction": "Choisis le bon mot.",
+ "question": "Le petit garçon ... sur la glace.",
+ "answers": [
+ "glisse",
+ "glise"
+ ],
+ "correct_answer": 0,
+ "hint": "Le son [s] se trouve entre le « i » et le « e ».",
+ "explanation": "Pour entendre [s] entre deux voyelles, on écrit « ss » : « glisse ».",
+ "review_tags": [
+ "sons_lettres",
+ "s_ss"
+ ],
+ "review_status": "draft"
+}
diff --git a/data/localization/fr.json b/data/localization/fr.json
index 2403f0b..89eaedb 100644
--- a/data/localization/fr.json
+++ b/data/localization/fr.json
@@ -11,11 +11,7 @@
"profile_create.confirm_button": "Commencer l'aventure",
"profile_create.cancel_button": "Retour",
"profile_create.name_required_error": "Choisis un prénom avant de continuer.",
- "world_map.title": "La forêt des accords",
"world_map.guide_hint": "Choisis un lieu pour commencer l'aventure !",
- "world_map.title_ce2": "Le marais des homophones",
- "world_map.review_ce1_button": "Revoir le CE1",
- "world_map.back_to_ce2_button": "Retour au CE2",
"world_map.status_decouverte": "À découvrir",
"world_map.status_en_acquisition": "En entraînement",
"world_map.status_en_consolidation": "En progrès",
@@ -117,5 +113,12 @@
"adult.delete_confirm_message": "Effacer pour toujours le profil « %s » et toute sa progression ? Pense à l'exporter avant si tu veux pouvoir le restaurer.",
"adult.delete_confirm_yes": "Oui, supprimer",
"adult.delete_confirm_no": "Non, annuler",
- "adult.delete_done": "Profil supprimé."
+ "adult.delete_done": "Profil supprimé.",
+ "world_map.review_level_button": "Revoir le %s",
+ "world_map.back_to_level_button": "Retour au %s",
+ "world_map.other_region_button": "Aller : %s",
+ "profile_create.level_cm1": "CM1",
+ "profile_create.level_cm2": "CM2",
+ "skill.sons_et_lettres": "Sons et lettres",
+ "world_map.coming_soon": "Ce niveau arrive bientôt !"
}
diff --git a/data/missions/mission_22_seau_et_pelle.json b/data/missions/mission_22_seau_et_pelle.json
new file mode 100644
index 0000000..8b00a01
--- /dev/null
+++ b/data/missions/mission_22_seau_et_pelle.json
@@ -0,0 +1,12 @@
+{
+ "mission_id": "mission_22_seau_et_pelle",
+ "title": "Le seau et la pelle",
+ "narrative_context": "Sur la plage, un seau est rempli de mots. Choisis la bonne écriture pour que le château de sable tienne debout !",
+ "rule_id": "regle_s_ss",
+ "exercise_type": "multiple_choice",
+ "map_position": {
+ "x": 0.15,
+ "y": 0.15
+ },
+ "review_status": "draft"
+}
diff --git a/data/missions/mission_23_panier_coquillages.json b/data/missions/mission_23_panier_coquillages.json
new file mode 100644
index 0000000..b54c2fe
--- /dev/null
+++ b/data/missions/mission_23_panier_coquillages.json
@@ -0,0 +1,12 @@
+{
+ "mission_id": "mission_23_panier_coquillages",
+ "title": "Le panier à coquillages",
+ "narrative_context": "Des coquillages portent un mot mal écrit. Trouve celui qui cloche et remets-le d'aplomb.",
+ "rule_id": "regle_s_ss",
+ "exercise_type": "sentence_correction",
+ "map_position": {
+ "x": 0.5,
+ "y": 0.15
+ },
+ "review_status": "draft"
+}
diff --git a/data/missions/mission_24_coquille_murmure.json b/data/missions/mission_24_coquille_murmure.json
new file mode 100644
index 0000000..208a090
--- /dev/null
+++ b/data/missions/mission_24_coquille_murmure.json
@@ -0,0 +1,12 @@
+{
+ "mission_id": "mission_24_coquille_murmure",
+ "title": "La coquille qui murmure",
+ "narrative_context": "Colle ton oreille à la coquille : elle murmure une phrase, une seule fois. Écoute bien et écris-la en entier.",
+ "rule_id": "regle_s_ss",
+ "exercise_type": "dictation",
+ "map_position": {
+ "x": 0.85,
+ "y": 0.15
+ },
+ "review_status": "draft"
+}
diff --git a/data/missions/mission_25_phare_du_petit_c.json b/data/missions/mission_25_phare_du_petit_c.json
new file mode 100644
index 0000000..cfcce29
--- /dev/null
+++ b/data/missions/mission_25_phare_du_petit_c.json
@@ -0,0 +1,12 @@
+{
+ "mission_id": "mission_25_phare_du_petit_c",
+ "title": "Le phare du petit ç",
+ "narrative_context": "Le gardien du phare a perdu une cédille. Aide-le à choisir la bonne écriture pour guider les bateaux.",
+ "rule_id": "regle_c_cedille",
+ "exercise_type": "multiple_choice",
+ "map_position": {
+ "x": 0.15,
+ "y": 0.5
+ },
+ "review_status": "draft"
+}
diff --git a/data/missions/mission_26_balancoire_du_port.json b/data/missions/mission_26_balancoire_du_port.json
new file mode 100644
index 0000000..29e758d
--- /dev/null
+++ b/data/missions/mission_26_balancoire_du_port.json
@@ -0,0 +1,12 @@
+{
+ "mission_id": "mission_26_balancoire_du_port",
+ "title": "La balançoire du port",
+ "narrative_context": "Sur le port, des lettres se sont balancées trop loin. Trouve le mot fautif et corrige-le.",
+ "rule_id": "regle_c_cedille",
+ "exercise_type": "sentence_correction",
+ "map_position": {
+ "x": 0.5,
+ "y": 0.5
+ },
+ "review_status": "draft"
+}
diff --git a/data/missions/mission_27_cerf_volant_parleur.json b/data/missions/mission_27_cerf_volant_parleur.json
new file mode 100644
index 0000000..e772390
--- /dev/null
+++ b/data/missions/mission_27_cerf_volant_parleur.json
@@ -0,0 +1,12 @@
+{
+ "mission_id": "mission_27_cerf_volant_parleur",
+ "title": "Le cerf-volant parleur",
+ "narrative_context": "Le cerf-volant te souffle une phrase, une seule fois. Écoute-le bien et écris exactement ce qu'il dit.",
+ "rule_id": "regle_c_cedille",
+ "exercise_type": "dictation",
+ "map_position": {
+ "x": 0.85,
+ "y": 0.5
+ },
+ "review_status": "draft"
+}
diff --git a/data/missions/mission_28_chateau_de_sable.json b/data/missions/mission_28_chateau_de_sable.json
new file mode 100644
index 0000000..a41c12b
--- /dev/null
+++ b/data/missions/mission_28_chateau_de_sable.json
@@ -0,0 +1,12 @@
+{
+ "mission_id": "mission_28_chateau_de_sable",
+ "title": "Le château de sable",
+ "narrative_context": "Ton château de sable a des tours en « m » et en « n ». Choisis la bonne lettre pour qu'il résiste aux vagues.",
+ "rule_id": "regle_m_devant_mbp",
+ "exercise_type": "multiple_choice",
+ "map_position": {
+ "x": 0.15,
+ "y": 0.85
+ },
+ "review_status": "draft"
+}
diff --git a/data/missions/mission_29_bateau_des_pompiers.json b/data/missions/mission_29_bateau_des_pompiers.json
new file mode 100644
index 0000000..163a780
--- /dev/null
+++ b/data/missions/mission_29_bateau_des_pompiers.json
@@ -0,0 +1,12 @@
+{
+ "mission_id": "mission_29_bateau_des_pompiers",
+ "title": "Le bateau des pompiers",
+ "narrative_context": "Les pompiers du port ont un message mal écrit. Repère le mot fautif et remplace-le par le bon.",
+ "rule_id": "regle_m_devant_mbp",
+ "exercise_type": "sentence_correction",
+ "map_position": {
+ "x": 0.5,
+ "y": 0.85
+ },
+ "review_status": "draft"
+}
diff --git a/data/missions/mission_30_tambour_de_la_plage.json b/data/missions/mission_30_tambour_de_la_plage.json
new file mode 100644
index 0000000..4fe1e46
--- /dev/null
+++ b/data/missions/mission_30_tambour_de_la_plage.json
@@ -0,0 +1,12 @@
+{
+ "mission_id": "mission_30_tambour_de_la_plage",
+ "title": "Le tambour de la plage",
+ "narrative_context": "Le tambour de la plage bat une phrase, une seule fois. Écoute bien et écris-la sans faute.",
+ "rule_id": "regle_m_devant_mbp",
+ "exercise_type": "dictation",
+ "map_position": {
+ "x": 0.85,
+ "y": 0.85
+ },
+ "review_status": "draft"
+}
diff --git a/data/rules/regle_c_cedille.json b/data/rules/regle_c_cedille.json
new file mode 100644
index 0000000..895f158
--- /dev/null
+++ b/data/rules/regle_c_cedille.json
@@ -0,0 +1,9 @@
+{
+ "rule_id": "regle_c_cedille",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "statement": "Devant a, o ou u, la lettre « c » se prononce [k]. Pour entendre le son [s], on écrit « ç » avec une cédille.",
+ "example": "Un garçon. Une leçon. Ça va.",
+ "review_status": "draft"
+}
diff --git a/data/rules/regle_m_devant_mbp.json b/data/rules/regle_m_devant_mbp.json
new file mode 100644
index 0000000..5459d09
--- /dev/null
+++ b/data/rules/regle_m_devant_mbp.json
@@ -0,0 +1,9 @@
+{
+ "rule_id": "regle_m_devant_mbp",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "statement": "Devant les lettres m, b et p, on écrit « m » et non « n ».",
+ "example": "Une lampe. Une jambe. Un timbre.",
+ "review_status": "draft"
+}
diff --git a/data/rules/regle_s_ss.json b/data/rules/regle_s_ss.json
new file mode 100644
index 0000000..9eb3594
--- /dev/null
+++ b/data/rules/regle_s_ss.json
@@ -0,0 +1,9 @@
+{
+ "rule_id": "regle_s_ss",
+ "level": "CE1",
+ "domain": "orthographe",
+ "skill": "sons_et_lettres",
+ "statement": "Entre deux voyelles, la lettre « s » seule se prononce [z]. Pour entendre le son [s], on écrit « ss ».",
+ "example": "Une rose. Un poisson. Une tasse.",
+ "review_status": "draft"
+}
diff --git a/data/schemas/curriculum.schema.json b/data/schemas/curriculum.schema.json
index 46565c8..3b72e2e 100644
--- a/data/schemas/curriculum.schema.json
+++ b/data/schemas/curriculum.schema.json
@@ -10,10 +10,13 @@
],
"additionalProperties": false,
"properties": {
- "level": { "type": "string", "enum": ["CE1", "CE2"] },
+ "level": { "type": "string", "enum": ["CE1", "CE2", "CM1", "CM2"] },
"region_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"chapter_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"title": { "type": "string", "minLength": 1 },
+ "region_title": { "type": "string", "minLength": 1 },
+ "order": { "type": "integer", "minimum": 1, "maximum": 3 },
+ "backdrop": { "type": "string", "enum": ["forest", "marsh", "beach"] },
"rule_ids": {
"type": "array",
"minItems": 1,
diff --git a/data/schemas/dictation.schema.json b/data/schemas/dictation.schema.json
index d32120d..3f5520d 100644
--- a/data/schemas/dictation.schema.json
+++ b/data/schemas/dictation.schema.json
@@ -16,7 +16,7 @@
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
- "level": { "type": "string", "enum": ["CE1", "CE2"] },
+ "level": { "type": "string", "enum": ["CE1", "CE2", "CM1", "CM2"] },
"domain": { "type": "string", "enum": ["grammaire", "orthographe"] },
"rule_ids": {
"type": "array",
diff --git a/data/schemas/exercise_multiple_choice.schema.json b/data/schemas/exercise_multiple_choice.schema.json
index 7f86b10..cfa6860 100644
--- a/data/schemas/exercise_multiple_choice.schema.json
+++ b/data/schemas/exercise_multiple_choice.schema.json
@@ -20,7 +20,7 @@
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
- "level": { "type": "string", "enum": ["CE1", "CE2"] },
+ "level": { "type": "string", "enum": ["CE1", "CE2", "CM1", "CM2"] },
"domain": { "type": "string", "enum": ["grammaire", "orthographe"] },
"skill": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
diff --git a/data/schemas/exercise_sentence_correction.schema.json b/data/schemas/exercise_sentence_correction.schema.json
index 1d532f0..cf78581 100644
--- a/data/schemas/exercise_sentence_correction.schema.json
+++ b/data/schemas/exercise_sentence_correction.schema.json
@@ -18,7 +18,7 @@
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
- "level": { "type": "string", "enum": ["CE1", "CE2"] },
+ "level": { "type": "string", "enum": ["CE1", "CE2", "CM1", "CM2"] },
"domain": { "type": "string", "enum": ["grammaire", "orthographe"] },
"skill": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
diff --git a/data/schemas/rule.schema.json b/data/schemas/rule.schema.json
index 81d49ca..2ba33f6 100644
--- a/data/schemas/rule.schema.json
+++ b/data/schemas/rule.schema.json
@@ -12,7 +12,7 @@
"additionalProperties": false,
"properties": {
"rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
- "level": { "type": "string", "enum": ["CE1", "CE2"] },
+ "level": { "type": "string", "enum": ["CE1", "CE2", "CM1", "CM2"] },
"domain": { "type": "string", "enum": ["grammaire", "orthographe"] },
"skill": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"statement": { "type": "string", "minLength": 1 },
diff --git a/data/schemas/save.schema.json b/data/schemas/save.schema.json
index f03f570..2eebbab 100644
--- a/data/schemas/save.schema.json
+++ b/data/schemas/save.schema.json
@@ -16,7 +16,7 @@
"schema_version": { "type": "integer", "minimum": 1 },
"profile_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"display_name": { "type": "string", "minLength": 1 },
- "school_level": { "type": "string", "enum": ["CE1", "CE2"] },
+ "school_level": { "type": "string", "enum": ["CE1", "CE2", "CM1", "CM2"] },
"session_count": { "type": "integer", "minimum": 0 },
"unlocked_regions": { "type": "array", "items": { "type": "string" } },
"skills": {
diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md
index 742f1d9..4e9d3d5 100644
--- a/docs/specs/decisions.md
+++ b/docs/specs/decisions.md
@@ -358,3 +358,17 @@ Ajouts à l'arborescence proposée :
## 37. Suppression d'un profil réservée à l'adulte (version 0.5.6)
Demande utilisateur. Le bouton « Supprimer » est retiré de la sélection de profil (côté enfant) ; la suppression se fait dans l'espace adulte, derrière la barrière (point 32), via « Supprimer ce profil » avec une confirmation explicite qui nomme le profil et rappelle de l'exporter avant. Une suppression n'affecte jamais un autre profil. Le cahier des charges §9 (confirmation explicite) est respecté.
+
+## 38. Plusieurs « tableaux » par niveau, niveaux CM1/CM2, tableau pilote (version 0.6.0)
+
+Demande utilisateur : chaque niveau peut avoir jusqu'à 3 tableaux (lieux : forêt, marais, plage…) traitant d'autres règles de grammaire et d'orthographe, avec la même exigence « aucune phrase en double », et le jeu s'ouvre à CM1 et CM2 sur le même principe. Les notions s'inspirent de ce que travaillent les cahiers de ce type ; **seules les règles sont reprises, tous les textes sont originaux** (invariant du projet : rien du BLED n'est reproduit, son nom n'apparaît pas).
+
+**Format (validé par l'utilisateur avant implémentation)** : le niveau scolaire des schémas (`save`, `rule`, `curriculum`, `dictation`, exercices) passe de CE1/CE2 à CE1/CE2/CM1/CM2. Ajout compatible : aucun profil existant n'est migré, `schema_version` reste 2 ; une ancienne version du jeu ne lirait pas un profil CM. La liste est unique (`SchoolLevels`, testée contre les schémas).
+
+**Tableau = fichier de `data/curriculum/`** : nouveaux champs optionnels `region_title` (nom affiché), `order` (1 à 3) et `backdrop` (décor, liste fermée `BackdropKinds`, testée contre l'art disponible). Une mission appartient au tableau qui contient sa règle : aucun champ de plus dans les missions ni dans les sauvegardes. Garde-fous (`test_curriculum`) : une règle dans un seul tableau, du bon niveau, 3 tableaux maximum par niveau, toute mission rattachée à un tableau.
+
+**Carte** : bouton « Aller : » pour passer d'un tableau à l'autre ; « Revoir le » / « Retour au » généralisé ; on revient au dernier tableau consulté après une mission. Décor et musique automatique suivent le tableau (plage → morceau « rivière »). Création de profil : seuls les niveaux ayant du contenu sont proposés (CM1/CM2 apparaîtront avec leur premier tableau).
+
+**Tableau pilote CE1 « La plage des sons »** (`order` 2, décor plage) : 3 règles (s/ss entre deux voyelles, c/ç devant a-o-u, m devant m-b-p), 24 QCM, 12 corrections, 9 dictées (voix Piper provisoire, audio généré), 9 missions, compétence « sons et lettres ». Contenu entièrement `draft`. Unicité : tous les énoncés passent les gardes-fous de phrases en double et quasi doubles (points 34 et 36).
+
+**Reste à faire** : CE1 tableau 3 (« La montagne des verbes »), CE2 tableaux 2 et 3, CM1 et CM2 (3 tableaux chacun), d'autres décors et musiques par tableau. **Non vérifié** : lisibilité des nouvelles voix de dictée, pertinence pédagogique des règles et du niveau de difficulté (à relire), autres résolutions.
diff --git a/docs/testing/manual-checklist.md b/docs/testing/manual-checklist.md
index 3b1683d..02615cb 100644
--- a/docs/testing/manual-checklist.md
+++ b/docs/testing/manual-checklist.md
@@ -22,6 +22,12 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a
13. Quitter le jeu puis relancer `make run` : le profil et sa progression doivent être conservés (sélectionner le même profil, vérifier que l'état affiché sur la carte a changé par rapport à l'état initial).
14. La suppression d'un profil n'est plus sur la sélection de profil (point 37 de `decisions.md`) : Réglages → Espace adulte (barrière) → choisir le profil → « Supprimer ce profil » : une confirmation explicite nomme le profil ; annuler ne supprime rien ; les autres profils restent intacts.
+## Tableaux supplémentaires (point 38 de `decisions.md`)
+
+- Profil CE1 : la carte « La forêt des accords » propose « Aller : La plage des sons » ; le tableau de la plage s'affiche avec son décor (mer, sable), ses 9 lieux sans chevauchement, et le bouton ramène à la forêt. Après une mission, on revient au tableau d'où l'on vient.
+- Jouer une mission de chaque règle de la plage (s/ss, ç, m devant m-b-p), dont une dictée : voix claire, énoncés sans répétition, indices et explications justes.
+- Création de profil : seuls CE1 et CE2 sont proposés (CM1/CM2 n'ont pas encore de contenu).
+
## Accessibilité et interface
- Habillage (point 31 de `decisions.md`) : relire chaque écran (profils, carte CE1 et CE2, introduction, règle, les trois types d'exercice, retour juste/faux, récompense, collection) ; le rendu à 1152x648 a été relu sur captures, pas les autres tailles ni le tactile. Redimensionner la fenêtre : les lieux de la carte restent entiers et ne se chevauchent pas.
diff --git a/project.godot b/project.godot
index 56f8108..8487842 100644
--- a/project.godot
+++ b/project.godot
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="Les Aventuriers des mots"
-config/version="0.5.6"
+config/version="0.6.0"
run/main_scene="res://scenes/boot/boot.tscn"
config/features=PackedStringArray("4.6", "Forward Plus")
config/icon="res://icon.svg"
diff --git a/scenes/exercises/exercise_host.gd b/scenes/exercises/exercise_host.gd
index 106d4ce..b7b5c61 100644
--- a/scenes/exercises/exercise_host.gd
+++ b/scenes/exercises/exercise_host.gd
@@ -65,8 +65,9 @@ func _ready() -> void:
_rule = ExerciseEngine.get_rule(_mission["rule_id"])
_skill_id = _rule.get("skill", "")
_current_difficulty = ProgressManager.next_difficulty_for_skill(_skill_id, 1)
- $Backdrop.kind = "marsh" if _rule.get("level", "CE1") == "CE2" else "forest"
- AudioManager.play_region_music(_rule.get("level", "CE1"))
+ var backdrop_kind := ExerciseEngine.backdrop_for_rule(_mission["rule_id"])
+ $Backdrop.kind = backdrop_kind
+ AudioManager.play_region_music(backdrop_kind)
_show_rule_panel()
diff --git a/scenes/profiles/profile_create.gd b/scenes/profiles/profile_create.gd
index dfe3bb4..36b6bdd 100644
--- a/scenes/profiles/profile_create.gd
+++ b/scenes/profiles/profile_create.gd
@@ -7,8 +7,7 @@ extends Control
@onready var _name_label: Label = %NameLabel
@onready var _name_edit: LineEdit = %NameEdit
@onready var _level_label: Label = %LevelLabel
-@onready var _ce1_button: Button = %CE1Button
-@onready var _ce2_button: Button = %CE2Button
+@onready var _level_box: HBoxContainer = %LevelBox
@onready var _error_label: Label = %ErrorLabel
@onready var _confirm_button: Button = %ConfirmButton
@onready var _cancel_button: Button = %CancelButton
@@ -19,26 +18,52 @@ func _ready() -> void:
_name_label.text = UIText.get_text("profile_create.name_label")
_name_edit.placeholder_text = UIText.get_text("profile_create.name_placeholder")
_level_label.text = UIText.get_text("profile_create.level_label")
- _ce1_button.text = UIText.get_text("profile_create.level_ce1")
- _ce2_button.text = UIText.get_text("profile_create.level_ce2")
_error_label.text = UIText.get_text("profile_create.name_required_error")
_error_label.visible = false
_confirm_button.text = UIText.get_text("profile_create.confirm_button")
_cancel_button.text = UIText.get_text("profile_create.cancel_button")
- _ce1_button.button_pressed = true
+ _build_level_buttons()
_confirm_button.pressed.connect(_on_confirm_pressed)
_cancel_button.pressed.connect(_on_cancel_pressed)
+## Un bouton par niveau (SchoolLevels.ALL), un seul actif a la fois ; CE1 par
+## defaut.
+func _build_level_buttons() -> void:
+ var group := ButtonGroup.new()
+ for level in SchoolLevels.ALL:
+ # Un niveau sans contenu n'est pas propose (il apparait avec ses tableaux).
+ if ExerciseEngine.get_regions_for_level(level).is_empty():
+ continue
+ var button := Button.new()
+ button.text = UIText.get_text("profile_create.level_%s" % level.to_lower())
+ button.theme_type_variation = &"SecondaryButton"
+ button.toggle_mode = true
+ button.button_group = group
+ button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ button.custom_minimum_size = Vector2(0, 56)
+ button.set_meta("level", level)
+ button.button_pressed = _level_box.get_child_count() == 0
+ _level_box.add_child(button)
+
+
+func _selected_level() -> String:
+ for child in _level_box.get_children():
+ var button := child as Button
+ if button != null and button.button_pressed:
+ return button.get_meta("level")
+ return "CE1"
+
+
func _on_confirm_pressed() -> void:
var display_name := _name_edit.text.strip_edges()
if display_name.is_empty():
_error_label.visible = true
return
- var school_level := "CE1" if _ce1_button.button_pressed else "CE2"
+ var school_level := _selected_level()
var profile_id := ProfileManager.create_profile(display_name, school_level)
if ProfileManager.select_profile(profile_id):
GameManager.goto(GameManager.SCENE_WORLD_MAP)
diff --git a/scenes/profiles/profile_create.tscn b/scenes/profiles/profile_create.tscn
index bafb7f6..1ac8f2e 100644
--- a/scenes/profiles/profile_create.tscn
+++ b/scenes/profiles/profile_create.tscn
@@ -1,11 +1,9 @@
-[gd_scene load_steps=4 format=3]
+[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scenes/profiles/profile_create.gd" id="1"]
[ext_resource type="PackedScene" path="res://scenes/ui/backdrop.tscn" id="backdrop"]
-[sub_resource type="ButtonGroup" id="SchoolLevelGroup"]
-
[node name="ProfileCreate" type="Control"]
layout_mode = 3
anchors_preset = 15
@@ -71,27 +69,10 @@ unique_name_in_owner = true
layout_mode = 2
[node name="LevelBox" type="HBoxContainer" parent="Margin/Center/Card/VBox"]
+unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 16
-[node name="CE1Button" type="Button" parent="Margin/Center/Card/VBox/LevelBox"]
-unique_name_in_owner = true
-layout_mode = 2
-size_flags_horizontal = 3
-custom_minimum_size = Vector2(0, 56)
-theme_type_variation = &"SecondaryButton"
-toggle_mode = true
-button_group = SubResource("SchoolLevelGroup")
-
-[node name="CE2Button" type="Button" parent="Margin/Center/Card/VBox/LevelBox"]
-unique_name_in_owner = true
-layout_mode = 2
-size_flags_horizontal = 3
-custom_minimum_size = Vector2(0, 56)
-theme_type_variation = &"SecondaryButton"
-toggle_mode = true
-button_group = SubResource("SchoolLevelGroup")
-
[node name="ConfirmButton" type="Button" parent="Margin/Center/Card/VBox"]
unique_name_in_owner = true
layout_mode = 2
diff --git a/scenes/profiles/profile_select.gd b/scenes/profiles/profile_select.gd
index 27029ca..f0d3b66 100644
--- a/scenes/profiles/profile_select.gd
+++ b/scenes/profiles/profile_select.gd
@@ -17,7 +17,7 @@ func _ready() -> void:
_settings_button.pressed.connect(_on_settings_pressed)
_new_profile_button.pressed.connect(_on_new_profile_pressed)
- AudioManager.play_region_music("CE1")
+ AudioManager.play_region_music("forest")
_refresh_profile_list()
diff --git a/scenes/ui/backdrop.gd b/scenes/ui/backdrop.gd
index 84fe7bc..b9dcd4f 100644
--- a/scenes/ui/backdrop.gd
+++ b/scenes/ui/backdrop.gd
@@ -1,13 +1,9 @@
extends Control
## Decor plein ecran derriere un ecran (illustration vectorielle provisoire,
-## voir assets/CREDITS.md). "forest" pour le CE1, "marsh" pour le CE2.
+## voir assets/CREDITS.md). Le decor est celui du « tableau » (BackdropKinds) ;
+## un identifiant inconnu retombe sur la foret.
-const TEXTURES := {
- "forest": "res://assets/environments/forest_backdrop.svg",
- "marsh": "res://assets/environments/marsh_backdrop.svg",
-}
-
-@export_enum("forest", "marsh") var kind: String = "forest":
+@export var kind: String = "forest":
set(value):
kind = value
_apply()
@@ -21,4 +17,5 @@ func _ready() -> void:
func _apply() -> void:
if _picture != null:
- _picture.texture = load(TEXTURES.get(kind, TEXTURES["forest"]))
+ var path: String = BackdropKinds.TEXTURES.get(kind, BackdropKinds.TEXTURES["forest"])
+ _picture.texture = load(path)
diff --git a/scenes/ui/reward_screen.gd b/scenes/ui/reward_screen.gd
index a3d7af9..c668f4e 100644
--- a/scenes/ui/reward_screen.gd
+++ b/scenes/ui/reward_screen.gd
@@ -26,9 +26,9 @@ const ITEM_SIZE := 72.0
func _ready() -> void:
var mission := ExerciseEngine.get_mission(GameManager.pending_mission_id)
- var rule := ExerciseEngine.get_rule(mission.get("rule_id", ""))
- _backdrop.kind = "marsh" if rule.get("level", "CE1") == "CE2" else "forest"
- AudioManager.play_region_music(rule.get("level", "CE1"))
+ var backdrop_kind := ExerciseEngine.backdrop_for_rule(mission.get("rule_id", ""))
+ _backdrop.kind = backdrop_kind
+ AudioManager.play_region_music(backdrop_kind)
var summary := ProgressManager.complete_mission(
GameManager.pending_mission_id, GameManager.last_outcomes
diff --git a/scenes/ui/settings.gd b/scenes/ui/settings.gd
index 2006f19..cd95392 100644
--- a/scenes/ui/settings.gd
+++ b/scenes/ui/settings.gd
@@ -95,7 +95,7 @@ func _build_track_option() -> void:
func _on_track_selected(index: int) -> void:
SettingsManager.set_music_track(SoundCatalog.music_choices()[index])
# On fait entendre le choix tout de suite.
- AudioManager.play_region_music(ProgressManager.school_level())
+ AudioManager.play_region_music(AudioManager.current_backdrop_kind())
func _on_volume_changed(value: float) -> void:
diff --git a/scenes/world/mission_intro.gd b/scenes/world/mission_intro.gd
index 80c3cf7..cba4921 100644
--- a/scenes/world/mission_intro.gd
+++ b/scenes/world/mission_intro.gd
@@ -13,8 +13,7 @@ func _ready() -> void:
var mission := ExerciseEngine.get_mission(GameManager.pending_mission_id)
_title_label.text = mission.get("title", "")
_guide.say(mission.get("narrative_context", ""))
- var rule := ExerciseEngine.get_rule(mission.get("rule_id", ""))
- $Backdrop.kind = "marsh" if rule.get("level", "CE1") == "CE2" else "forest"
+ $Backdrop.kind = ExerciseEngine.backdrop_for_rule(mission.get("rule_id", ""))
_start_button.text = UIText.get_text("mission_intro.start_button")
_back_button.text = UIText.get_text("mission_intro.back_button")
diff --git a/scenes/world/world_map.gd b/scenes/world/world_map.gd
index 63e9a16..744bccf 100644
--- a/scenes/world/world_map.gd
+++ b/scenes/world/world_map.gd
@@ -4,17 +4,20 @@ extends Control
## etendu (docs/specs/decisions.md, points 25 et 27). La position de chaque
## mission vient de data/missions/*.json (fractions 0..1 de la zone de
## carte), jamais d'une position fixe en dur dans le script : chaque lieu est
-## replace quand la zone change de taille. Un profil CE2 peut basculer sur la
-## carte CE1 pour revoir les notions precedentes.
+## replace quand la zone change de taille. Chaque niveau a un a trois
+## « tableaux » (data/curriculum/) entre lesquels on circule ; un profil peut
+## basculer sur le niveau precedent pour revoir les notions deja vues.
const MARKER_SCENE := preload("res://scenes/world/mission_marker.tscn")
var _viewed_level := "CE1"
+var _viewed_region_id := ""
@onready var _backdrop: Control = %Backdrop
@onready var _title_label: Label = %TitleLabel
@onready var _map_area: Control = %MapArea
@onready var _level_toggle_button: Button = %LevelToggleButton
+@onready var _region_button: Button = %RegionButton
@onready var _total_stars_label: Label = %TotalStarsLabel
@onready var _collection_button: Button = %CollectionButton
@onready var _profile_button: Button = %ProfileButton
@@ -24,8 +27,9 @@ var _viewed_level := "CE1"
func _ready() -> void:
_viewed_level = ProgressManager.school_level()
- _level_toggle_button.visible = _viewed_level == "CE2"
+ _viewed_region_id = _initial_region_id()
_level_toggle_button.pressed.connect(_on_level_toggle_pressed)
+ _region_button.pressed.connect(_on_region_pressed)
_collection_button.text = UIText.get_text("world_map.collection_button")
_collection_button.pressed.connect(_on_collection_pressed)
_profile_button.text = UIText.get_text("world_map.change_profile_button")
@@ -38,24 +42,63 @@ func _ready() -> void:
func _refresh() -> void:
- var is_ce2 := _viewed_level == "CE2"
- _backdrop.kind = "marsh" if is_ce2 else "forest"
- AudioManager.play_region_music(_viewed_level)
- _title_label.text = UIText.get_text("world_map.title_ce2" if is_ce2 else "world_map.title")
+ var own_level := ProgressManager.school_level()
+ var previous_level := SchoolLevels.previous(own_level)
+ var regions := ExerciseEngine.get_regions_for_level(_viewed_level)
+ var region := ExerciseEngine.get_region(_viewed_region_id)
+
+ var kind: String = region.get("backdrop", BackdropKinds.FALLBACK)
+ _backdrop.kind = kind if BackdropKinds.is_valid(kind) else BackdropKinds.FALLBACK
+ AudioManager.play_region_music(_backdrop.kind)
+ _title_label.text = region.get("region_title", region.get("title", ""))
+
+ # Niveau precedent : « Revoir le CE1 » / « Retour au CE2 ».
+ _level_toggle_button.visible = previous_level != ""
+ var target_level := own_level if _viewed_level != own_level else previous_level
_level_toggle_button.text = UIText.get_text(
- "world_map.review_ce1_button" if is_ce2 else "world_map.back_to_ce2_button"
- )
+ "world_map.review_level_button"
+ if _viewed_level == own_level
+ else "world_map.back_to_level_button"
+ ) % target_level
+
+ # Autres tableaux du niveau affiche : un bouton qui passe au suivant.
+ _region_button.visible = regions.size() > 1
+ if regions.size() > 1:
+ var next_region := regions[(_region_index(regions) + 1) % regions.size()]
+ _region_button.text = (
+ UIText.get_text("world_map.other_region_button")
+ % next_region.get("region_title", next_region["title"])
+ )
_total_stars_label.text = str(ProgressManager.stars())
+ if region.is_empty():
+ _title_label.text = UIText.get_text("world_map.coming_soon")
_populate_missions()
+## Tableau a afficher a l'ouverture : celui d'ou l'on vient s'il appartient
+## au niveau du profil, sinon le premier du niveau.
+func _initial_region_id() -> String:
+ var regions := ExerciseEngine.get_regions_for_level(_viewed_level)
+ for region in regions:
+ if region["region_id"] == GameManager.map_region_id:
+ return region["region_id"]
+ return regions[0]["region_id"] if not regions.is_empty() else ""
+
+
+func _region_index(regions: Array[Dictionary]) -> int:
+ for i in regions.size():
+ if regions[i]["region_id"] == _viewed_region_id:
+ return i
+ return 0
+
+
func _populate_missions() -> void:
for child in _map_area.get_children():
_map_area.remove_child(child)
child.queue_free()
var index := 0
- for mission: Dictionary in ExerciseEngine.get_missions_for_level(_viewed_level):
+ for mission: Dictionary in ExerciseEngine.get_missions_for_region(_viewed_region_id):
var marker: Button = MARKER_SCENE.instantiate()
_map_area.add_child(marker)
marker.set_meta("map_position", mission["map_position"])
@@ -126,12 +169,26 @@ func _on_collection_pressed() -> void:
func _on_level_toggle_pressed() -> void:
- _viewed_level = "CE1" if _viewed_level == "CE2" else "CE2"
+ var own_level := ProgressManager.school_level()
+ _viewed_level = (
+ SchoolLevels.previous(own_level) if _viewed_level == own_level else own_level
+ )
+ _viewed_region_id = _initial_region_id()
+ _refresh()
+
+
+func _on_region_pressed() -> void:
+ var regions := ExerciseEngine.get_regions_for_level(_viewed_level)
+ if regions.size() < 2:
+ return
+ _viewed_region_id = regions[(_region_index(regions) + 1) % regions.size()]["region_id"]
+ GameManager.map_region_id = _viewed_region_id
_refresh()
func _on_mission_pressed(mission_id: String) -> void:
GameManager.pending_mission_id = mission_id
+ GameManager.map_region_id = _viewed_region_id
GameManager.goto(GameManager.SCENE_MISSION_INTRO)
diff --git a/scenes/world/world_map.tscn b/scenes/world/world_map.tscn
index c5fa4cf..cb4ad62 100644
--- a/scenes/world/world_map.tscn
+++ b/scenes/world/world_map.tscn
@@ -96,6 +96,13 @@ theme_override_constants/separation = 12
unique_name_in_owner = true
layout_mode = 2
+[node name="RegionButton" type="Button" parent="Margin/VBox/BottomBar"]
+unique_name_in_owner = true
+theme_type_variation = &"SecondaryButton"
+layout_mode = 2
+size_flags_vertical = 4
+custom_minimum_size = Vector2(0, 52)
+
[node name="LevelToggleButton" type="Button" parent="Margin/VBox/BottomBar"]
unique_name_in_owner = true
theme_type_variation = &"SecondaryButton"
diff --git a/scripts/audio/sound_catalog.gd b/scripts/audio/sound_catalog.gd
index d143255..44e6e4c 100644
--- a/scripts/audio/sound_catalog.gd
+++ b/scripts/audio/sound_catalog.gd
@@ -9,7 +9,7 @@ const MUSIC_DIR := "res://assets/audio/music"
const SFX_IDS: Array[String] = ["click", "success", "try_again", "star", "fanfare"]
const MUSIC_IDS: Array[String] = ["forest", "marsh", "meadow", "river", "night"]
-## Choix « automatique » : la musique de la region (voir music_for_level).
+## Choix « automatique » : la musique du decor du tableau (BackdropKinds).
const MUSIC_AUTO := "auto"
@@ -25,11 +25,6 @@ static func music_path(music_id: String) -> String:
return "%s/%s.ogg" % [MUSIC_DIR, music_id]
-## Musique d'une region selon le niveau scolaire de la regle.
-static func music_for_level(level: String) -> String:
- return "marsh" if level == "CE2" else "forest"
-
-
## Choix possibles dans les reglages : automatique puis chaque morceau.
static func music_choices() -> Array[String]:
var choices: Array[String] = [MUSIC_AUTO]
@@ -37,8 +32,9 @@ static func music_choices() -> Array[String]:
return choices
-## Morceau a jouer pour le choix de l'utilisateur et le niveau scolaire.
-static func resolve_music(choice: String, level: String) -> String:
+## Morceau a jouer pour le choix de l'utilisateur et le decor du tableau :
+## un morceau choisi vaut partout ; « automatique » suit le decor.
+static func resolve_music(choice: String, backdrop_kind: String) -> String:
if MUSIC_IDS.has(choice):
return choice
- return music_for_level(level)
+ return BackdropKinds.music_for(backdrop_kind)
diff --git a/scripts/profiles/school_levels.gd b/scripts/profiles/school_levels.gd
new file mode 100644
index 0000000..155b1b1
--- /dev/null
+++ b/scripts/profiles/school_levels.gd
@@ -0,0 +1,24 @@
+class_name SchoolLevels
+extends RefCounted
+## Niveaux scolaires du jeu, dans l'ordre de progression. Code pur. La liste
+## est fermee : elle est aussi celle des schemas (data/schemas/*.json), et
+## un test verifie que les deux concordent.
+
+const ALL: Array[String] = ["CE1", "CE2", "CM1", "CM2"]
+
+
+static func is_valid(level: String) -> bool:
+ return ALL.has(level)
+
+
+## Niveau juste avant `level` (celui que l'on peut « revoir »), ou "" pour
+## le premier niveau ou une valeur inconnue.
+static func previous(level: String) -> String:
+ var index := ALL.find(level)
+ return ALL[index - 1] if index > 0 else ""
+
+
+## Rang du niveau (0 pour CE1), grand pour un niveau inconnu : sert au tri.
+static func rank(level: String) -> int:
+ var index := ALL.find(level)
+ return index if index >= 0 else ALL.size()
diff --git a/scripts/profiles/school_levels.gd.uid b/scripts/profiles/school_levels.gd.uid
new file mode 100644
index 0000000..d1e4adf
--- /dev/null
+++ b/scripts/profiles/school_levels.gd.uid
@@ -0,0 +1 @@
+uid://1padrqilrpin
diff --git a/scripts/ui/backdrop_kinds.gd b/scripts/ui/backdrop_kinds.gd
new file mode 100644
index 0000000..0cb7f9f
--- /dev/null
+++ b/scripts/ui/backdrop_kinds.gd
@@ -0,0 +1,36 @@
+class_name BackdropKinds
+extends RefCounted
+## Decors disponibles (un par « tableau » de la carte) et musique associee.
+## Liste fermee, resolue par identifiant vers un repertoire fixe ; un test
+## verifie qu'elle concorde avec l'enum `backdrop` de
+## data/schemas/curriculum.schema.json.
+
+const TEXTURES := {
+ "forest": "res://assets/environments/forest_backdrop.svg",
+ "marsh": "res://assets/environments/marsh_backdrop.svg",
+ "beach": "res://assets/environments/beach_backdrop.svg",
+}
+
+## Morceau joue par defaut avec chaque decor (reglage « automatique »).
+const MUSIC := {
+ "forest": "forest",
+ "marsh": "marsh",
+ "beach": "river",
+}
+
+const FALLBACK := "forest"
+
+
+static func kinds() -> Array[String]:
+ var result: Array[String] = []
+ for kind: String in TEXTURES:
+ result.append(kind)
+ return result
+
+
+static func is_valid(kind: String) -> bool:
+ return TEXTURES.has(kind)
+
+
+static func music_for(kind: String) -> String:
+ return MUSIC.get(kind, MUSIC[FALLBACK])
diff --git a/scripts/ui/backdrop_kinds.gd.uid b/scripts/ui/backdrop_kinds.gd.uid
new file mode 100644
index 0000000..6159315
--- /dev/null
+++ b/scripts/ui/backdrop_kinds.gd.uid
@@ -0,0 +1 @@
+uid://btn7aydf3704h
diff --git a/tests/audio/test_sound_catalog.gd b/tests/audio/test_sound_catalog.gd
index 382063c..b43c101 100644
--- a/tests/audio/test_sound_catalog.gd
+++ b/tests/audio/test_sound_catalog.gd
@@ -18,9 +18,9 @@ func test_unknown_ids_give_no_path() -> void:
assert_eq(SoundCatalog.music_path("inconnue"), "")
-func test_music_follows_the_level() -> void:
- assert_eq(SoundCatalog.music_for_level("CE1"), "forest")
- assert_eq(SoundCatalog.music_for_level("CE2"), "marsh")
+func test_music_follows_the_backdrop() -> void:
+ assert_eq(SoundCatalog.resolve_music("auto", "forest"), "forest")
+ assert_eq(SoundCatalog.resolve_music("auto", "marsh"), "marsh")
func test_there_are_five_music_tracks_and_an_automatic_choice() -> void:
@@ -30,9 +30,9 @@ func test_there_are_five_music_tracks_and_an_automatic_choice() -> void:
func test_resolve_music_follows_choice_or_level() -> void:
- assert_eq(SoundCatalog.resolve_music("night", "CE1"), "night")
- assert_eq(SoundCatalog.resolve_music("auto", "CE2"), "marsh")
- assert_eq(SoundCatalog.resolve_music("n_importe_quoi", "CE1"), "forest")
+ assert_eq(SoundCatalog.resolve_music("night", "forest"), "night")
+ assert_eq(SoundCatalog.resolve_music("auto", "beach"), "river")
+ assert_eq(SoundCatalog.resolve_music("n_importe_quoi", "inconnu"), "forest")
func test_every_music_choice_has_a_label() -> void:
diff --git a/tests/data/test_curriculum.gd b/tests/data/test_curriculum.gd
new file mode 100644
index 0000000..c73b1a4
--- /dev/null
+++ b/tests/data/test_curriculum.gd
@@ -0,0 +1,63 @@
+extends GutTest
+## Coherence des « tableaux » (data/curriculum/) avec le reste des donnees.
+
+
+func test_school_levels_match_the_schemas() -> void:
+ for schema_name in ["save", "rule", "curriculum", "dictation", "exercise_multiple_choice"]:
+ var schema := DataLoader.load_schema(schema_name)
+ var enum_values: Array = []
+ if schema_name == "save":
+ enum_values = schema["properties"]["school_level"]["enum"]
+ else:
+ enum_values = schema["properties"]["level"]["enum"]
+ assert_eq(enum_values, SchoolLevels.ALL as Array, "schema %s" % schema_name)
+
+
+func test_backdrop_enum_matches_available_art() -> void:
+ var schema := DataLoader.load_schema("curriculum")
+ var enum_values: Array = schema["properties"]["backdrop"]["enum"]
+ var kinds: Array = BackdropKinds.kinds()
+ enum_values.sort()
+ kinds.sort()
+ assert_eq(enum_values, kinds)
+ for kind in BackdropKinds.kinds():
+ assert_true(
+ ResourceLoader.exists(BackdropKinds.TEXTURES[kind]), "decor manquant : %s" % kind
+ )
+ assert_true(SoundCatalog.MUSIC_IDS.has(BackdropKinds.music_for(kind)))
+
+
+func test_every_rule_belongs_to_exactly_one_region_of_its_level() -> void:
+ var owners := {}
+ for level in SchoolLevels.ALL:
+ for region in ExerciseEngine.get_regions_for_level(level):
+ assert_between(
+ ExerciseEngine.get_regions_for_level(level).size(), 1, 3, "3 tableaux maximum"
+ )
+ for rule_id: String in region["rule_ids"]:
+ assert_false(owners.has(rule_id), "regle dans deux tableaux : %s" % rule_id)
+ owners[rule_id] = region["region_id"]
+ var rule := ExerciseEngine.get_rule(rule_id)
+ assert_false(rule.is_empty(), "regle inconnue : %s" % rule_id)
+ assert_eq(rule.get("level", ""), level, "niveau de %s" % rule_id)
+ for mission in ExerciseEngine.get_missions():
+ assert_true(
+ owners.has(mission["rule_id"]), "mission hors tableau : %s" % mission["mission_id"]
+ )
+
+
+func test_regions_have_a_child_facing_title_and_unique_order() -> void:
+ for level in SchoolLevels.ALL:
+ var orders := {}
+ for region in ExerciseEngine.get_regions_for_level(level):
+ assert_true(region.has("region_title"), "titre manquant : %s" % region["region_id"])
+ var order := int(region.get("order", 1))
+ assert_false(orders.has(order), "numero de tableau en double dans %s" % level)
+ orders[order] = true
+
+
+func test_school_levels_helpers() -> void:
+ assert_eq(SchoolLevels.previous("CE1"), "")
+ assert_eq(SchoolLevels.previous("CM1"), "CE2")
+ assert_eq(SchoolLevels.previous("n_importe_quoi"), "")
+ assert_true(SchoolLevels.rank("CM2") > SchoolLevels.rank("CE2"))
diff --git a/tests/data/test_curriculum.gd.uid b/tests/data/test_curriculum.gd.uid
new file mode 100644
index 0000000..d88f298
--- /dev/null
+++ b/tests/data/test_curriculum.gd.uid
@@ -0,0 +1 @@
+uid://domp0yvjne65v
diff --git a/tests/data/test_mission_positions.gd b/tests/data/test_mission_positions.gd
index 7acd8e6..82a84ff 100644
--- a/tests/data/test_mission_positions.gd
+++ b/tests/data/test_mission_positions.gd
@@ -13,20 +13,24 @@ const MIN_GAP_X := 0.2
const MIN_GAP_Y := 0.3
-func test_mission_markers_do_not_overlap_within_a_level() -> void:
- for level in ["CE1", "CE2"]:
- var missions := ExerciseEngine.get_missions_for_level(level)
- assert_false(missions.is_empty(), "aucune mission pour %s" % level)
- for i in missions.size():
- for j in range(i + 1, missions.size()):
- var a: Dictionary = missions[i]["map_position"]
- var b: Dictionary = missions[j]["map_position"]
- var dx: float = abs(float(a["x"]) - float(b["x"]))
- var dy: float = abs(float(a["y"]) - float(b["y"]))
- assert_true(
- dx >= MIN_GAP_X or dy >= MIN_GAP_Y,
- (
- "%s et %s sont trop proches (dx=%s, dy=%s)"
- % [missions[i]["mission_id"], missions[j]["mission_id"], dx, dy]
- ),
- )
+func test_mission_markers_do_not_overlap_within_a_region() -> void:
+ var checked_regions := 0
+ for level in SchoolLevels.ALL:
+ for region in ExerciseEngine.get_regions_for_level(level):
+ var missions := ExerciseEngine.get_missions_for_region(region["region_id"])
+ assert_false(missions.is_empty(), "aucune mission pour %s" % region["region_id"])
+ checked_regions += 1
+ for i in missions.size():
+ for j in range(i + 1, missions.size()):
+ var a: Dictionary = missions[i]["map_position"]
+ var b: Dictionary = missions[j]["map_position"]
+ var dx: float = abs(float(a["x"]) - float(b["x"]))
+ var dy: float = abs(float(a["y"]) - float(b["y"]))
+ assert_true(
+ dx >= MIN_GAP_X or dy >= MIN_GAP_Y,
+ (
+ "%s et %s sont trop proches (dx=%s, dy=%s)"
+ % [missions[i]["mission_id"], missions[j]["mission_id"], dx, dy]
+ ),
+ )
+ assert_gt(checked_regions, 1)
diff --git a/tests/exercises/test_exercise_engine.gd b/tests/exercises/test_exercise_engine.gd
index 4669a76..b0062d8 100644
--- a/tests/exercises/test_exercise_engine.gd
+++ b/tests/exercises/test_exercise_engine.gd
@@ -15,7 +15,7 @@ func test_unknown_rule_returns_empty_dictionary() -> void:
func test_missions_are_loaded_and_sorted() -> void:
var missions := ExerciseEngine.get_missions()
- assert_eq(missions.size(), 21)
+ assert_eq(missions.size(), 30)
var ids: Array = missions.map(func(m: Dictionary) -> String: return m["mission_id"])
assert_eq(ids, ids.duplicate()) # trivialement vrai, documente l'ordre stable
assert_true(ids.has("mission_01_pre_des_mots"))
@@ -24,11 +24,23 @@ func test_missions_are_loaded_and_sorted() -> void:
func test_missions_are_filtered_by_the_level_of_their_rule() -> void:
- assert_eq(ExerciseEngine.get_missions_for_level("CE1").size(), 9)
+ assert_eq(ExerciseEngine.get_missions_for_level("CE1").size(), 18)
assert_eq(ExerciseEngine.get_missions_for_level("CE2").size(), 12)
assert_eq(ExerciseEngine.get_missions_for_level("CM1").size(), 0)
+func test_regions_are_ordered_and_hold_their_missions() -> void:
+ var ce1 := ExerciseEngine.get_regions_for_level("CE1")
+ assert_eq(ce1.size(), 2)
+ assert_eq(ce1[0]["region_id"], "foret_des_accords")
+ assert_eq(ce1[1]["region_id"], "plage_des_sons")
+ assert_eq(ExerciseEngine.get_missions_for_region("foret_des_accords").size(), 9)
+ assert_eq(ExerciseEngine.get_missions_for_region("plage_des_sons").size(), 9)
+ assert_eq(ExerciseEngine.backdrop_for_rule("regle_s_ss"), "beach")
+ assert_eq(ExerciseEngine.backdrop_for_rule("regle_pluriel_nom"), "forest")
+ assert_eq(ExerciseEngine.backdrop_for_rule("regle_homophone_a_a"), "marsh")
+
+
func test_reward_catalog_is_loaded_and_sorted_by_threshold() -> void:
var items := ExerciseEngine.get_reward_items()
assert_eq(items.size(), 10)
diff --git a/tests/world/test_settings_scenes.gd b/tests/world/test_settings_scenes.gd
index 0915a7f..190ea62 100644
--- a/tests/world/test_settings_scenes.gd
+++ b/tests/world/test_settings_scenes.gd
@@ -70,3 +70,19 @@ func test_profile_deletion_belongs_to_the_adult_space() -> void:
assert_true(ProfileManager.list_profiles().has(other_id), "les autres profils sont intacts")
ProfileManager.delete_profile(other_id)
+
+
+func test_profile_creation_offers_only_levels_that_have_content() -> void:
+ var scene: Control = add_child_autofree(
+ load("res://scenes/profiles/profile_create.tscn").instantiate()
+ )
+ await wait_process_frames(1)
+ var offered: Array[String] = []
+ for button in scene.get_node("%LevelBox").get_children():
+ offered.append(button.get_meta("level"))
+ var expected: Array[String] = []
+ for level in SchoolLevels.ALL:
+ if not ExerciseEngine.get_regions_for_level(level).is_empty():
+ expected.append(level)
+ assert_eq(offered, expected)
+ assert_true(offered.has("CE1") and offered.has("CE2"))
diff --git a/tests/world/test_world_map.gd b/tests/world/test_world_map.gd
index 9cd4766..019af38 100644
--- a/tests/world/test_world_map.gd
+++ b/tests/world/test_world_map.gd
@@ -24,6 +24,7 @@ func test_ce1_profile_sees_only_ce1_missions_and_no_toggle() -> void:
assert_eq(map.get_node("%MapArea").get_child_count(), 9)
assert_false(map.get_node("%LevelToggleButton").visible)
+ GameManager.map_region_id = ""
func test_ce2_profile_sees_ce2_missions_and_can_review_ce1() -> void:
@@ -31,20 +32,40 @@ func test_ce2_profile_sees_ce2_missions_and_can_review_ce1() -> void:
await wait_process_frames(1)
assert_eq(map.get_node("%MapArea").get_child_count(), 12)
- assert_eq(map.get_node("%TitleLabel").text, UIText.get_text("world_map.title_ce2"))
+ assert_eq(map.get_node("%TitleLabel").text, "Le marais des homophones")
var toggle: Button = map.get_node("%LevelToggleButton")
assert_true(toggle.visible)
toggle.pressed.emit()
await wait_process_frames(1)
assert_eq(map.get_node("%MapArea").get_child_count(), 9)
- assert_eq(map.get_node("%TitleLabel").text, UIText.get_text("world_map.title"))
+ assert_eq(map.get_node("%TitleLabel").text, "La forêt des accords")
+ assert_eq(toggle.text, "Retour au CE2")
toggle.pressed.emit()
await wait_process_frames(1)
assert_eq(map.get_node("%MapArea").get_child_count(), 12)
+func test_ce1_profile_can_move_between_its_regions() -> void:
+ var map := _open_map_for("CE1")
+ await wait_process_frames(1)
+ assert_eq(map.get_node("%TitleLabel").text, "La forêt des accords")
+ var button: Button = map.get_node("%RegionButton")
+ assert_true(button.visible)
+ assert_eq(map.get_node("%MapArea").get_child_count(), 9)
+
+ button.pressed.emit()
+ await wait_process_frames(1)
+ assert_eq(map.get_node("%TitleLabel").text, "La plage des sons")
+ assert_eq(map.get_node("%MapArea").get_child_count(), 9)
+
+ button.pressed.emit()
+ await wait_process_frames(1)
+ assert_eq(map.get_node("%TitleLabel").text, "La forêt des accords")
+ GameManager.map_region_id = ""
+
+
func test_map_offers_a_way_back_to_the_profile_selection() -> void:
var map := _open_map_for("CE1")
await wait_process_frames(1)
diff --git a/tools/screenshot.gd b/tools/screenshot.gd
index f653165..b134692 100644
--- a/tools/screenshot.gd
+++ b/tools/screenshot.gd
@@ -43,7 +43,7 @@ func _initialize() -> void:
await process_frame
# Actions optionnelles sur exercise_host, a partir du 5e argument :
# continue_rule | answer: | toggle: | type: | validate
- # | continue_feedback | solve_gate (espace adulte)
+ # | continue_feedback | solve_gate (espace adulte) | call:
for action in args.slice(4):
if action.begins_with("outcomes:") or action.begins_with("text_scale:"):
continue
@@ -60,6 +60,10 @@ func _initialize() -> void:
func _run_action(scene: Node, action: String) -> void:
var parts := action.split(":", true, 1)
+ if parts[0] == "call":
+ # Appelle une methode de la scene (ex. call:_on_region_pressed).
+ scene.call(parts[1])
+ return
if parts[0] == "solve_gate":
# Espace adulte : saisit la bonne reponse a la barriere.
var input: LineEdit = scene.find_child("GateInput", true, false)