Files
Forge-Engine/db/dialogue_lines.py
T
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

109 lines
4.5 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.
from typing import Any
_MAX_LINES_PER_COLUMN = 200
_MAX_SPEAKER_LENGTH = 60
# Question à choix (voir "❓ Question", static/game/js/triggers/trigger-editor.js) :
# une bulle jaune posée dans le même enchaînement qu'une réplique de
# dialogue — plusieurs choix, une seule bonne réponse, une récompense.
# Un seul type de récompense pour l'instant (le score, voir db.SCORE_STATUS_*/
# db.set_score_value) — liste plutôt qu'un booléen pour pouvoir en ajouter
# d'autres plus tard sans changer la forme des données.
QUESTION_REWARD_TYPES = ["score"]
_MIN_CHOICES = 2
_MAX_CHOICES = 4
def _sanitize_dialogue_line(raw_line: dict[str, Any]) -> dict[str, Any] | None:
speaker = raw_line.get("speaker")
text = raw_line.get("text")
if not isinstance(speaker, str) or not speaker.strip():
return None
if not isinstance(text, str) or not text.strip():
return None
line = {"type": "dialogue", "speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()}
# Réplique audio (demande explicite : "ajouter une réplique audio")
# — une voix off jouée en même temps que la bulle s'affiche, voir
# "Mes assets" (un fichier déjà importé, jamais un chemin arbitraire
# posté à la main : la sélection se fait via un <select>, pas un
# champ texte libre, voir static/game/js/triggers/trigger-editor.js).
# Optionnel : une réplique reste valide sans voix off.
audio_url = raw_line.get("audio_url")
if isinstance(audio_url, str) and audio_url.strip():
line["audio_url"] = audio_url.strip()
return line
def _sanitize_question_line(raw_line: dict[str, Any]) -> dict[str, Any] | None:
text = raw_line.get("text")
if not isinstance(text, str) or not text.strip():
return None
raw_choices = raw_line.get("choices")
if not isinstance(raw_choices, list):
return None
choices = [c.strip() for c in raw_choices if isinstance(c, str) and c.strip()][:_MAX_CHOICES]
if len(choices) < _MIN_CHOICES:
return None
raw_correct_index = raw_line.get("correct_index")
if raw_correct_index is None:
return None
try:
correct_index = int(raw_correct_index)
except (TypeError, ValueError):
return None
if not (0 <= correct_index < len(choices)):
return None
reward_type = raw_line.get("reward_type")
if reward_type not in QUESTION_REWARD_TYPES:
reward_type = QUESTION_REWARD_TYPES[0]
try:
reward_amount = max(0, int(raw_line.get("reward_amount", 0)))
except (TypeError, ValueError):
reward_amount = 0
return {
"type": "question",
"text": text.strip(),
"choices": choices,
"correct_index": correct_index,
"reward_type": reward_type,
"reward_amount": reward_amount,
}
def _sanitize_line(raw_line: Any) -> dict[str, Any] | None:
if not isinstance(raw_line, dict):
return None
if raw_line.get("type") == "question":
return _sanitize_question_line(raw_line)
return _sanitize_dialogue_line(raw_line)
def sanitize_dialogue_lines(raw_lines: Any) -> list[dict[str, Any]]:
"""Sanitize une LISTE de répliques de dialogue ({type: "dialogue",
speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de
scène, ou "Joueur", voir game_engine/rendering/scene_object_names.py)
ET/OU de questions à choix ({type: "question", text, choices,
correct_index, reward_type, reward_amount}, voir QUESTION_REWARD_TYPES
ci-dessus) — même esprit que resolve_collision_rules.sanitize_collision_rules :
rejette tout élément invalide plutôt que de lever. Les lignes d'UN
dialogue embarqué dans une action "dialogue" (voir
game_engine/rendering/collision_rules.py) : chaque occurrence de
"Déclencher ce dialogue" porte le sien."""
if not isinstance(raw_lines, list):
return []
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
return [line for line in cleaned if line]
def sum_question_rewards(lines: Any) -> int:
"""Somme des récompenses de toutes les questions ("❓ Question" ci-
dessus) d'UNE liste de répliques/questions — utilisée pour calculer le
score max possible du quiz à l'export SCORM, sur TOUTES les lignes de
TOUS les dialogues du jeu (voir game_engine.collect_all_dialogue_lines,
publish/build_scorm_package.py)."""
total = 0
for line in lines or []:
if isinstance(line, dict) and line.get("type") == "question":
total += line.get("reward_amount", 0) or 0
return total