Nouveau type de ligne dans un dialogue de quête (voir
db/quests/sanitize_quest_dialogues.py) : une bulle JAUNE, dans la même
chaîne verticale que les répliques (même alignement, reliée par un
trait) — header : nombre de choix (2 à 8) + récompense (score, montant
en points) ; body : la question, ses choix et un bouton radio pour
désigner la bonne réponse. Aucune limite au nombre de questions par
colonne, comme pour une réplique.
Les lignes de dialogue portent maintenant explicitement
{"type": "dialogue", ...} (au lieu d'un objet sans type) pour
distinguer les deux formes — migration de forme, tests mis à jour.
Portée de ce commit : l'ÉDITEUR (créer/modifier une question). Le jeu
lui-même (afficher la question au joueur, vérifier la réponse,
attribuer la récompense) reste à construire — le widget "💬 Boîte de
dialogue" ne sait aujourd'hui afficher qu'une réplique.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
195 lines
9.2 KiB
Python
195 lines
9.2 KiB
Python
"""Éditeur de quêtes (voir db/quests/, routes/quests/, onglet "🗺️ Quêtes"
|
||
de l'éditeur de scène 2D, templates/scene_edit.html) : id, titre, objectif, récompense
|
||
(score), statut (nouvelle/en_cours/terminee), résultat (reussi/echoue),
|
||
et un dialogue par statut (3 colonnes — voir static/js/quests/
|
||
quest-editor.js et sanitize_quest_dialogues). Une quête vit pour TOUT le
|
||
jeu, jamais par écran (mêmes conventions que les événements
|
||
personnalisés, db/custom_events/)."""
|
||
import db
|
||
|
||
|
||
def _make_game(tmp_game_slug_cleanup, name="pytest_quests"):
|
||
return tmp_game_slug_cleanup(db.create_game(name))
|
||
|
||
|
||
def test_create_quest_has_sensible_defaults(tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
assert quest["title"] == "Trouver la clé"
|
||
assert quest["objectif"] == ""
|
||
assert quest["recompense_score"] == 0
|
||
assert quest["status"] == "nouvelle"
|
||
assert quest["resultat"] is None
|
||
assert quest["dialogues"] == {}
|
||
|
||
|
||
def test_create_quest_falls_back_to_default_title_when_blank(tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, " ")
|
||
assert quest["title"] == "Nouvelle quête"
|
||
|
||
|
||
def test_list_quests_orders_most_recent_first(tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
first = db.create_quest(slug, "Première")
|
||
second = db.create_quest(slug, "Seconde")
|
||
ids = [q["id"] for q in db.list_quests(slug)]
|
||
assert ids[:2] == [second["id"], first["id"]]
|
||
|
||
|
||
def test_update_quest_only_applies_known_valid_fields(tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
updated = db.update_quest(slug, quest["id"], {
|
||
"title": "Trouver la clé dorée", "objectif": "Fouiller le donjon",
|
||
"recompense_score": 50, "status": "en_cours", "resultat": "reussi",
|
||
"unknown_field": "ignoré", "id": 999999,
|
||
})
|
||
assert updated["title"] == "Trouver la clé dorée"
|
||
assert updated["objectif"] == "Fouiller le donjon"
|
||
assert updated["recompense_score"] == 50
|
||
assert updated["status"] == "en_cours"
|
||
assert updated["resultat"] == "reussi"
|
||
assert updated["id"] == quest["id"] # "id" dans fields est ignoré, jamais modifiable
|
||
|
||
|
||
def test_update_quest_rejects_invalid_status_and_blank_title(tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
updated = db.update_quest(slug, quest["id"], {"status": "en_pause", "title": " "})
|
||
assert updated["status"] == "nouvelle" # inchangé, valeur invalide ignorée
|
||
assert updated["title"] == "Trouver la clé" # inchangé, titre vide ignoré
|
||
|
||
|
||
def test_update_quest_returns_none_for_missing_quest(tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
assert db.update_quest(slug, 999999, {"title": "x"}) is None
|
||
|
||
|
||
def test_delete_quest_removes_it(tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
db.delete_quest(slug, quest["id"])
|
||
assert db.get_quest(slug, quest["id"]) is None
|
||
|
||
|
||
def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status():
|
||
"""speaker est un nom de PERSONNAGE libre (voir "ℹ️ Informations",
|
||
screens/rendering/scene_object_names.py) — "Le Sorcier" est donc un nom
|
||
valide comme n'importe quel autre, seul un speaker/texte VIDE ou un
|
||
élément mal formé est rejeté."""
|
||
raw = {
|
||
"nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}, {"speaker": "Joueur", "text": " "}],
|
||
"en_cours": [{"speaker": " ", "text": "sans nom"}, {"speaker": 42, "text": "nom non textuel"}, "pas un objet"],
|
||
"terminee": "pas une liste",
|
||
"statut_inconnu": [{"speaker": "Le Sorcier", "text": "ignoré"}],
|
||
}
|
||
cleaned = db.sanitize_quest_dialogues(raw)
|
||
assert cleaned == {
|
||
"nouvelle": [{"type": "dialogue", "speaker": "Le Sorcier", "text": "Bonjour !"}],
|
||
"en_cours": [],
|
||
"terminee": [],
|
||
}
|
||
|
||
|
||
def test_sanitize_quest_dialogues_accepts_a_valid_question_and_rejects_malformed_ones():
|
||
""""❓ Question" (voir static/js/quests/quest-editor.js) : une bulle
|
||
jaune avec plusieurs choix, une seule bonne réponse, une récompense —
|
||
même chaîne qu'une réplique de dialogue, un autre "type" de ligne."""
|
||
raw = {
|
||
"nouvelle": [
|
||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2},
|
||
{"type": "question", "text": "Une seule option", "choices": ["seule"], "correct_index": 0}, # < 2 choix -> rejetée
|
||
{"type": "question", "text": "index hors bornes", "choices": ["a", "b"], "correct_index": 5}, # rejetée
|
||
{"type": "question", "text": " ", "choices": ["a", "b"], "correct_index": 0}, # texte vide -> rejetée
|
||
{"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "vie"},
|
||
],
|
||
"en_cours": [], "terminee": [],
|
||
}
|
||
cleaned = db.sanitize_quest_dialogues(raw)
|
||
assert cleaned["nouvelle"] == [
|
||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2},
|
||
# reward_type invalide -> repli sur le premier type connu ("score"), jamais rejetée pour ça seul
|
||
{"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "score", "reward_amount": 0},
|
||
]
|
||
|
||
|
||
def test_set_quest_dialogues_persists_all_three_columns(tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
dialogues = {
|
||
"nouvelle": [
|
||
{"type": "dialogue", "speaker": "pnj", "text": "Peux-tu m'aider ?"},
|
||
{"type": "dialogue", "speaker": "joueur", "text": "Bien sûr !"},
|
||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_type": "score", "reward_amount": 2},
|
||
],
|
||
"en_cours": [{"type": "dialogue", "speaker": "pnj", "text": "Alors, cette clé ?"}],
|
||
"terminee": [{"type": "dialogue", "speaker": "pnj", "text": "Merci beaucoup !"}],
|
||
}
|
||
db.set_quest_dialogues(slug, quest["id"], dialogues)
|
||
reloaded = db.get_quest(slug, quest["id"])
|
||
assert reloaded["dialogues"] == dialogues
|
||
|
||
|
||
def test_set_quest_dialogues_returns_none_for_missing_quest(tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
assert db.set_quest_dialogues(slug, 999999, {}) is None
|
||
|
||
|
||
# ---------- Routes ----------
|
||
|
||
def test_quest_new_route_creates_and_returns_json(client, tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
resp = client.post(f"/game/{slug}/quests/new", data='{"title": "Nouvelle quête du donjon"}', content_type="application/json")
|
||
assert resp.status_code == 200
|
||
quest = resp.get_json()["quest"]
|
||
assert quest["title"] == "Nouvelle quête du donjon"
|
||
assert db.get_quest(slug, quest["id"]) is not None
|
||
|
||
|
||
def test_quests_list_json_route_returns_all_quests(client, tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
db.create_quest(slug, "A")
|
||
db.create_quest(slug, "B")
|
||
resp = client.get(f"/game/{slug}/quests/list.json")
|
||
titles = {q["title"] for q in resp.get_json()["quests"]}
|
||
assert titles == {"A", "B"}
|
||
|
||
|
||
def test_quest_update_route_persists_fields(client, tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
resp = client.post(f"/game/{slug}/quests/{quest['id']}", data='{"recompense_score": 100, "status": "terminee"}', content_type="application/json")
|
||
assert resp.get_json()["quest"]["recompense_score"] == 100
|
||
assert resp.get_json()["quest"]["status"] == "terminee"
|
||
|
||
|
||
def test_quest_update_route_404s_for_missing_quest(client, game):
|
||
resp = client.post(f"/game/{game}/quests/999999", data="{}", content_type="application/json")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
def test_quest_delete_route_removes_it(client, tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
resp = client.post(f"/game/{slug}/quests/{quest['id']}/delete")
|
||
assert resp.get_json() == {"ok": True}
|
||
assert db.get_quest(slug, quest["id"]) is None
|
||
|
||
|
||
def test_quest_dialogues_route_sanitizes_and_persists(client, tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
resp = client.post(
|
||
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||
data='{"dialogues": {"nouvelle": [{"speaker": "pnj", "text": "Salut !"}], "en_cours": [], "terminee": []}}',
|
||
content_type="application/json",
|
||
)
|
||
assert resp.get_json()["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]
|
||
assert db.get_quest(slug, quest["id"])["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]
|
||
|
||
|
||
def test_quest_dialogues_route_404s_for_missing_quest(client, game):
|
||
resp = client.post(f"/game/{game}/quests/999999/dialogues", data='{"dialogues": {}}', content_type="application/json")
|
||
assert resp.status_code == 404
|