Files
Forge-Engine/tests/test_quests.py
T
williamandClaude Sonnet 5 b5546df044
Build and deploy / test-python (push) Failing after 1m34s
Build and deploy / test-js (push) Successful in 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Quiz jouable : budget de récompense, boîte à quiz, widget score
- 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>
2026-09-03 15:42:42 +02:00

265 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""É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