- Récompense d'une quête = budget MAXIMUM pour ses questions : la somme des points des "❓ Question" ne peut plus dépasser recompense_score (routes/quests/quest_dialogues.py à l'enregistrement des dialogues, quest_update.py si on abaisse la récompense sous ce qui est déjà réparti) — message d'erreur explicite (400) dans les deux sens, affiché via alert() côté éditeur (quest-editor.js), jamais enregistré silencieusement dans un état incohérent. - Nombre de choix par question plafonné à 4 (au lieu de 8). - Deux nouveaux widgets "🖥️ Interface" (mêmes fondations que "💬 Boîte de dialogue" — screens/rendering/dialogue_box_style.py, même panneau "🎨 Style") : - "❓ Boîte à quiz" : affiche la question et ses choix, sans pied (une question se résout au clic sur un choix, pas de "Suivant"). - "🏆 Score" : affiche en continu les points gagnés, TOUJOURS visible une fois posé (contrairement aux boîtes de dialogue/quiz, masquées par défaut). - Moteur d'exécution (static/js/play/dialogue-box-controller.js, refonte) : une conversation de quête alterne maintenant répliques (boîte de dialogue) et questions (boîte à quiz) selon le type de chaque ligne. Bonne réponse -> crédite le score (compteur runtime dédié, jamais lié au score du moteur document) et avance ; mauvaise réponse -> rien, la question reste affichée pour réessayer ; aucune boîte à quiz posée -> la question est ignorée plutôt que de bloquer la conversation. Le dialogue "en_cours" épuisé (donc, s'il contenait des questions, toutes répondues) fait passer la quête "terminee" (en mémoire seulement, comme le reste de ce système — voir accepter/ refuser une offre de quête) : le joueur peut alors quitter la scène normalement, ces widgets n'étant jamais des obstacles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
265 lines
12 KiB
Python
265 lines
12 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 json
|
||
|
||
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
|
||
|
||
|
||
def _question_line(reward_amount, correct_index=0):
|
||
return {
|
||
"type": "question", "text": "2+2 ?", "choices": ["3", "4"],
|
||
"correct_index": correct_index, "reward_type": "score", "reward_amount": reward_amount,
|
||
}
|
||
|
||
|
||
def test_quest_dialogues_route_rejects_questions_totaling_more_than_the_reward(client, tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
db.update_quest(slug, quest["id"], {"recompense_score": 5})
|
||
resp = client.post(
|
||
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||
data=json.dumps({"dialogues": {"nouvelle": [_question_line(3), _question_line(3)], "en_cours": [], "terminee": []}}),
|
||
content_type="application/json",
|
||
)
|
||
assert resp.status_code == 400
|
||
assert "pts" in resp.get_json()["error"]
|
||
# Rien n'a été enregistré — la répartition invalide n'est jamais persistée.
|
||
assert db.get_quest(slug, quest["id"])["dialogues"] == {}
|
||
|
||
|
||
def test_quest_dialogues_route_accepts_questions_totaling_exactly_the_reward(client, tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
db.update_quest(slug, quest["id"], {"recompense_score": 5})
|
||
resp = client.post(
|
||
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||
data=json.dumps({"dialogues": {"nouvelle": [_question_line(2), _question_line(3)], "en_cours": [], "terminee": []}}),
|
||
content_type="application/json",
|
||
)
|
||
assert resp.status_code == 200
|
||
|
||
|
||
def test_quest_update_route_rejects_lowering_reward_below_already_allocated_questions(client, tmp_game_slug_cleanup):
|
||
slug = _make_game(tmp_game_slug_cleanup)
|
||
quest = db.create_quest(slug, "Trouver la clé")
|
||
db.update_quest(slug, quest["id"], {"recompense_score": 10})
|
||
db.set_quest_dialogues(slug, quest["id"], {"nouvelle": [_question_line(6)], "en_cours": [], "terminee": []})
|
||
resp = client.post(
|
||
f"/game/{slug}/quests/{quest['id']}",
|
||
data=json.dumps({"recompense_score": 3}),
|
||
content_type="application/json",
|
||
)
|
||
assert resp.status_code == 400
|
||
assert "pts" in resp.get_json()["error"]
|
||
assert db.get_quest(slug, quest["id"])["recompense_score"] == 10 # inchangé
|
||
|
||
|
||
def test_sanitize_quest_dialogues_caps_choices_at_four():
|
||
raw = {
|
||
"nouvelle": [{"type": "question", "text": "Q", "choices": ["a", "b", "c", "d", "e"], "correct_index": 4, "reward_type": "score", "reward_amount": 1}],
|
||
"en_cours": [], "terminee": [],
|
||
}
|
||
cleaned = db.sanitize_quest_dialogues(raw)
|
||
# Coupé à 4 choix -> l'index 4 (qui pointait sur le 5e, retiré) sort des bornes -> ligne rejetée.
|
||
assert cleaned["nouvelle"] == []
|
||
|
||
|
||
def test_quest_dialogues_total_reward_sums_questions_across_all_statuses():
|
||
dialogues = {
|
||
"nouvelle": [_question_line(2)],
|
||
"en_cours": [_question_line(3), {"type": "dialogue", "speaker": "pnj", "text": "x"}],
|
||
"terminee": [],
|
||
}
|
||
assert db.quest_dialogues_total_reward(dialogues) == 5
|