Dialogues de quête : bulles illimitées, n'importe quel objet, timeline reliée
Build and deploy / test-python (push) Successful in 7m32s
Build and deploy / test-js (push) Successful in 1m2s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- "qui parle" n'est plus réservé au personnage : N'IMPORTE QUEL objet de
  scène nommé (personnage, décor, fond — voir "ℹ️ Informations",
  screens/rendering/scene_object_names.py, ex-personnage_names.py) peut
  parler dans un dialogue.
- Confirmé/documenté : aucune limite au nombre de répliques par colonne
  (bouton "+ Réplique" reste toujours disponible).
- Bulle = header (menu déroulant du "qui parle") + body (le texte),
  centrée dans sa colonne à 70% de largeur, alignée verticalement et
  reliée à la suivante par un trait — remplace l'ancien alignement
  gauche/droite façon chat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-03 11:54:06 +02:00
co-authored by Claude Sonnet 5
parent 2347a70c45
commit 521792fe00
11 changed files with 105 additions and 67 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ _MAX_SPEAKER_LENGTH = 60
def sanitize_quest_dialogues(raw_dialogues): def sanitize_quest_dialogues(raw_dialogues):
"""Une réplique = {speaker: '<nom du personnage>', text: '...'} — le """Une réplique = {speaker: '<nom du personnage>', text: '...'} — le
nom vient du champ "ℹ️ Informations" d'un objet de scène kind= nom vient du champ "ℹ️ Informations" d'un objet de scène kind=
"personnage" (voir screens/rendering/personnage_names.py), ou "personnage" (voir screens/rendering/scene_object_names.py), ou
"Joueur" (toujours proposé, voir static/js/quests/quest-editor.js) : "Joueur" (toujours proposé, voir static/js/quests/quest-editor.js) :
texte libre côté serveur (jamais un enum fixe — n'importe quel nom texte libre côté serveur (jamais un enum fixe — n'importe quel nom
donné à un personnage doit être accepté), juste borné en longueur. donné à un personnage doit être accepté), juste borné en longueur.
+4 -3
View File
@@ -141,7 +141,8 @@ def render_scene_edit(slug, screen_id, game):
# (seul moment où questPickerHtml irait la chercher sinon). # (seul moment où questPickerHtml irait la chercher sinon).
quests_json=json.dumps(db.list_quests(slug)), quests_json=json.dumps(db.list_quests(slug)),
# "qui parle" dans le dialogue d'une quête (voir "ℹ️ Informations", # "qui parle" dans le dialogue d'une quête (voir "ℹ️ Informations",
# static/js/quests/quest-editor.js) : les noms donnés aux # static/js/quests/quest-editor.js) : les noms donnés à N'IMPORTE
# personnages, toutes scènes du jeu confondues. # QUEL objet de scène (personnage, décor, fond), toutes scènes du
personnage_names_json=json.dumps(screens.list_personnage_names(slug)), # jeu confondues.
scene_object_names_json=json.dumps(screens.list_named_scene_object_names(slug)),
) )
+1 -1
View File
@@ -88,7 +88,7 @@ from .rendering.collision_rules import (
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX, ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
) )
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
from .rendering.personnage_names import list_personnage_names from .rendering.scene_object_names import list_named_scene_object_names
from .rendering.dialogue_box_style import resolve_dialogue_box_style, sanitize_dialogue_box_style, DEFAULT_DIALOGUE_BOX_STYLE, FONT_WEIGHT_CHOICES from .rendering.dialogue_box_style import resolve_dialogue_box_style, sanitize_dialogue_box_style, DEFAULT_DIALOGUE_BOX_STYLE, FONT_WEIGHT_CHOICES
from .screens_repo.ensure_schema import ensure_schema from .screens_repo.ensure_schema import ensure_schema
-19
View File
@@ -1,19 +0,0 @@
from ..screens_repo.list_screens import list_screens
from ..scenes.list_scene_objects import list_scene_objects
def list_personnage_names(slug):
"""Noms distincts de tous les personnages nommés (voir "ℹ️
Informations", templates/scene_edit.html), toutes scènes 2D du jeu
confondues — une quête (voir db/quests/) est GAME-WIDE, son dialogue
peut donc mettre en scène un PNJ posé sur une autre scène que celle
où la règle de collision se déclenche. Ordre alphabétique, jamais de
doublon."""
names = set()
for s in list_screens(slug):
if s["kind"] != "jeu_2d":
continue
for o in list_scene_objects(slug, s["id"]):
if o["kind"] == "personnage" and o.get("name"):
names.add(o["name"])
return sorted(names)
+25
View File
@@ -0,0 +1,25 @@
from ..screens_repo.list_screens import list_screens
from ..scenes.list_scene_objects import list_scene_objects
# Le widget "💬 Boîte de dialogue" (kind="dialogue_box") n'est pas un
# acteur de la scène — il n'a pas de sens comme "qui parle" dans un
# dialogue de quête, contrairement à N'IMPORTE QUEL AUTRE objet
# (personnage, décor, fond).
_EXCLUDED_KINDS = {"dialogue_box"}
def list_named_scene_object_names(slug):
"""Noms distincts de tous les objets de scène nommés (voir "ℹ️
Informations", templates/scene_edit.html) — n'importe quel kind
(personnage, décor, fond), toutes scènes 2D du jeu confondues : une
quête (voir db/quests/) est GAME-WIDE, son dialogue peut donc mettre
en scène un objet posé sur une autre scène que celle où la règle de
collision se déclenche. Ordre alphabétique, jamais de doublon."""
names = set()
for s in list_screens(slug):
if s["kind"] != "jeu_2d":
continue
for o in list_scene_objects(slug, s["id"]):
if o["kind"] not in _EXCLUDED_KINDS and o.get("name"):
names.add(o["name"])
return sorted(names)
+1 -1
View File
@@ -9,7 +9,7 @@ def set_scene_object_name(slug, object_id, name):
partout ailleurs, mais jamais éditable avant le panneau "ℹ️ partout ailleurs, mais jamais éditable avant le panneau "ℹ️
Informations", templates/scene_edit.html). Pour un personnage, ce nom Informations", templates/scene_edit.html). Pour un personnage, ce nom
est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue
de quête (voir screens/rendering/personnage_names.py, static/js/ de quête (voir screens/rendering/scene_object_names.py, static/js/
quests/quest-editor.js).""" quests/quest-editor.js)."""
ensure_scene_schema(slug) ensure_scene_schema(slug)
conn = db.connect(slug) conn = db.connect(slug)
+19 -9
View File
@@ -27,14 +27,15 @@ var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee'];
// besoin (collisionRuleActionSummary). // besoin (collisionRuleActionSummary).
var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : []; var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : [];
// Noms de personnages nommés (voir "ℹ️ Informations", templates/ // Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/
// scene_edit.html) — proposés comme "qui parle" pour une réplique, en // scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
// plus de "Joueur" (toujours disponible, jamais un objet de scène). // seulement un personnage — proposés comme "qui parle" pour une réplique,
var PERSONNAGE_NAMES = (typeof PERSONNAGE_NAMES_JSON !== 'undefined') ? PERSONNAGE_NAMES_JSON : []; // en plus de "Joueur" (toujours disponible, jamais un objet de scène).
var SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : [];
// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/ // Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/
// .questBubble-pnj, static/style.css) même si le nom affiché est // .questBubble-pnj, static/style.css) même si le nom affiché est
// n'importe quel personnage nommé — "Joueur" à droite en vert, n'importe // n'importe quel objet nommé — "Joueur" en vert, n'importe
// quel autre nom à gauche en bleu (voir demande utilisateur : "les bulles // quel autre nom à gauche en bleu (voir demande utilisateur : "les bulles
// du pnj et du joueur sont d'une couleur différente"). // du pnj et du joueur sont d'une couleur différente").
function questBubbleKind(speaker) { function questBubbleKind(speaker) {
@@ -198,24 +199,33 @@ function renderQuestFieldsModalBody(questId) {
// renommé/supprimé depuis) pour ne jamais perdre silencieusement la // renommé/supprimé depuis) pour ne jamais perdre silencieusement la
// valeur déjà enregistrée. // valeur déjà enregistrée.
function questSpeakerOptions(currentSpeaker) { function questSpeakerOptions(currentSpeaker) {
var options = ['Joueur'].concat(PERSONNAGE_NAMES); var options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker); if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
return options; return options;
} }
// Une bulle = header (qui parle, menu déroulant des objets de scène
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
// distinctes demandées explicitement, pas un seul bloc. Toujours centrée
// dans sa colonne, 70% de la largeur (voir .questBubble, static/style.css) —
// aucune limite au nombre de bulles par colonne (voir questAddBubble).
function questDialogueBubbleHtml(line, status, index) { function questDialogueBubbleHtml(line, status, index) {
var kind = questBubbleKind(line.speaker); var kind = questBubbleKind(line.speaker);
var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) { var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) {
return '<option value="' + name.replace(/"/g, '&quot;') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? '🧑 Joueur' : '🗨️ ' + name) + '</option>'; return '<option value="' + name.replace(/"/g, '&quot;') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? '🧑 Joueur' : '🗨️ ' + name) + '</option>';
}).join(''); }).join('');
return ( return (
'<div class="questBubbleRow questBubbleRow-' + kind + '">' + '<div class="questBubbleRow">' +
'<div class="questBubble questBubble-' + kind + '">' + '<div class="questBubble questBubble-' + kind + '">' +
'<div class="questBubbleHeader">' +
'<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' + '<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
'</div>' +
'<div class="questBubbleBody">' +
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' + '<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' + 'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '&lt;') + '</textarea>' + 'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '&lt;') + '</textarea>' +
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' + '</div>' +
'</div>' + '</div>' +
'</div>' '</div>'
); );
@@ -324,7 +334,7 @@ function questSaveDialogues(quest) {
// — juste un défaut de départ, l'auteur choisit ensuite librement dans // — juste un défaut de départ, l'auteur choisit ensuite librement dans
// le menu déroulant de la réplique. // le menu déroulant de la réplique.
function questNextDefaultSpeaker(lastSpeaker) { function questNextDefaultSpeaker(lastSpeaker) {
if (lastSpeaker === 'Joueur') return PERSONNAGE_NAMES[0] || 'PNJ'; if (lastSpeaker === 'Joueur') return SCENE_OBJECT_NAMES[0] || 'PNJ';
return 'Joueur'; return 'Joueur';
} }
+26 -14
View File
@@ -817,31 +817,43 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
display:flex; flex-direction:column; gap:8px; min-height:160px; display:flex; flex-direction:column; gap:8px; min-height:160px;
} }
.questDialogueColumnTitle{ margin:0 0 2px; font-size:13px; text-align:center; } .questDialogueColumnTitle{ margin:0 0 2px; font-size:13px; text-align:center; }
.questDialogueColumnBubbles{ display:flex; flex-direction:column; gap:8px; flex:1; } /* Bulles alignées VERTICALEMENT, centrées dans la colonne, reliées par un
.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; } trait entre chacune (voir ::before sur .questBubbleRow ci-dessous) —
demandé explicitement plutôt qu'un alignement gauche/droite. */
.questDialogueColumnBubbles{ display:flex; flex-direction:column; align-items:center; flex:1; padding-top:4px; }
.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; margin-top:4px; }
.questBubbleRow{ display:flex; } .questBubbleRow{ display:flex; justify-content:center; width:100%; position:relative; }
.questBubbleRow-pnj{ justify-content:flex-start; } .questBubbleRow:not(:first-child)::before{
.questBubbleRow-joueur{ justify-content:flex-end; } content:''; position:absolute; top:-14px; left:50%; transform:translateX(-50%);
.questBubble{ width:2px; height:14px; background:var(--border);
display:flex; flex-direction:column; gap:4px; max-width:88%; border-radius:14px; padding:8px 10px;
animation:collisionLinkIn .25s ease backwards;
} }
.questBubble-pnj{ background:linear-gradient(135deg, #2f6fed, #1e4fb0); border-bottom-left-radius:4px; } /* Occupe 70% de la largeur de la colonne, header + body distincts (voir
.questBubble-joueur{ background:linear-gradient(135deg, #2ecc71, #1e9e57); border-bottom-right-radius:4px; } demande utilisateur) plutôt qu'un seul bloc — header : qui parle
(sélectionné parmi les objets de scène nommés) ; body : le texte. */
.questBubble{
width:70%; display:flex; flex-direction:column; border-radius:12px; overflow:hidden;
margin-bottom:14px; box-shadow:0 4px 14px rgba(0,0,0,.3); animation:collisionLinkIn .25s ease backwards;
}
.questBubbleHeader{ display:flex; align-items:center; justify-content:space-between; gap:6px; padding:6px 10px; }
.questBubbleBody{ padding:10px; }
.questBubble-pnj .questBubbleHeader{ background:#1e4fb0; }
.questBubble-pnj .questBubbleBody{ background:rgba(47,111,237,.16); }
.questBubble-joueur .questBubbleHeader{ background:#1e9e57; }
.questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); }
.questBubbleSpeaker{ .questBubbleSpeaker{
font-size:10.5px; font-weight:700; color:rgba(255,255,255,.85); cursor:pointer; font-size:11px; font-weight:700; color:#fff; cursor:pointer;
background:transparent; border:none; padding:0; font-family:inherit; max-width:100%; background:transparent; border:none; padding:0; font-family:inherit; max-width:100%; flex:1;
} }
.questBubbleSpeaker option{ color:#111; } .questBubbleSpeaker option{ color:#111; }
.questBubbleText{ .questBubbleText{
resize:none; overflow:hidden; border:none; background:transparent; color:#fff; font-size:13px; resize:none; overflow:hidden; border:none; background:transparent; color:#fff; font-size:13px;
font-family:inherit; padding:0; min-height:1.4em; font-family:inherit; padding:0; min-height:1.4em; width:100%;
} }
.questBubbleText::placeholder{ color:rgba(255,255,255,.6); } .questBubbleText::placeholder{ color:rgba(255,255,255,.6); }
.questBubbleText:focus{ outline:none; } .questBubbleText:focus{ outline:none; }
.questBubbleDelete{ .questBubbleDelete{
align-self:flex-end; background:none; border:none; color:rgba(255,255,255,.6); cursor:pointer; flex-shrink:0; background:none; border:none; color:rgba(255,255,255,.75); cursor:pointer;
font-size:11px; padding:0; transition:color .15s ease; font-size:11px; padding:0; transition:color .15s ease;
} }
.questBubbleDelete:hover{ color:#fff; } .questBubbleDelete:hover{ color:#fff; }
+12 -8
View File
@@ -268,16 +268,19 @@
</form> </form>
</div> </div>
{% if obj.kind == 'personnage' %} {% if obj.kind != 'dialogue_box' %}
<!-- "ℹ️ Informations" : le nom donné au personnage (voir <!-- "ℹ️ Informations" : le nom donné à L'OBJET (voir
screens/scenes/set_scene_object_name.py) — c'est CE nom qui est screens/scenes/set_scene_object_name.py) — n'importe quel kind
ensuite proposé comme "qui parle" dans l'éditeur de dialogue de (personnage, décor, fond), pas seulement un personnage — c'est
quête (voir "🗺️ Quêtes", static/js/quests/quest-editor.js). --> CE nom qui est ensuite proposé comme "qui parle" dans l'éditeur
de dialogue de quête (voir "🗺️ Quêtes", static/js/quests/
quest-editor.js) : un dialogue peut mettre en scène n'importe
quel objet, pas seulement un personnage. -->
<details class="propGroup" open> <details class="propGroup" open>
<summary>ℹ️ Informations</summary> <summary>ℹ️ Informations</summary>
<div class="propGroupBody"> <div class="propGroupBody">
<div class="field"> <div class="field">
<label class="label is-small">Nom du personnage</label> <label class="label is-small">Nom de l'objet</label>
<div class="control"><input class="input is-small" type="text" id="scene-obj-name" value="{{ obj.name or '' }}" placeholder="ex. Le Sorcier" onchange="saveSceneObjectName({{ obj.id }}, this.value)"></div> <div class="control"><input class="input is-small" type="text" id="scene-obj-name" value="{{ obj.name or '' }}" placeholder="ex. Le Sorcier" onchange="saveSceneObjectName({{ obj.id }}, this.value)"></div>
</div> </div>
</div> </div>
@@ -556,8 +559,9 @@
// "+ Action" ouvre directement cet éditeur (renderQuestPickerStep). // "+ Action" ouvre directement cet éditeur (renderQuestPickerStep).
var QUESTS_JSON = {{ quests_json|safe }}; var QUESTS_JSON = {{ quests_json|safe }};
// "qui parle" dans l'éditeur de dialogue (voir "ℹ️ Informations" + // "qui parle" dans l'éditeur de dialogue (voir "ℹ️ Informations" +
// static/js/quests/quest-editor.js::questSpeakerOptions). // static/js/quests/quest-editor.js::questSpeakerOptions) — n'importe
var PERSONNAGE_NAMES_JSON = {{ personnage_names_json|safe }}; // quel objet de scène nommé, pas seulement un personnage.
var SCENE_OBJECT_NAMES_JSON = {{ scene_object_names_json|safe }};
</script> </script>
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script> <script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script> <script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
+11 -6
View File
@@ -5,7 +5,7 @@ cours d'une quête déclenchée par une règle de collision "quete" (voir
static/js/play/dialogue-box-controller.js) — header/body/footer static/js/play/dialogue-box-controller.js) — header/body/footer
stylisables. Et le nom d'un personnage ("ℹ️ Informations", stylisables. Et le nom d'un personnage ("ℹ️ Informations",
screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans
l'éditeur de dialogue de quête (screens/rendering/personnage_names.py).""" l'éditeur de dialogue de quête (screens/rendering/scene_object_names.py)."""
import db import db
import screens import screens
@@ -125,8 +125,11 @@ def test_scene_object_name_route_persists(client, tmp_game_slug_cleanup):
assert screens.get_scene_object(slug, obj_id)["name"] == "Le Sorcier" assert screens.get_scene_object(slug, obj_id)["name"] == "Le Sorcier"
def test_list_personnage_names_is_alphabetical_and_deduped_across_scenes(tmp_game_slug_cleanup): def test_list_named_scene_object_names_is_alphabetical_deduped_and_includes_any_kind(tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(db.create_game("pytest_personnage_names")) """"il peut sélectionner le nom du personnage qui parle" -> étendu à
N'IMPORTE QUEL objet de scène nommé (pas seulement un personnage),
voir screens/rendering/scene_object_names.py."""
slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_object_names"))
scene_a = screens.create_screen(slug, "Scène A", kind="jeu_2d") scene_a = screens.create_screen(slug, "Scène A", kind="jeu_2d")
scene_b = screens.create_screen(slug, "Scène B", kind="jeu_2d") scene_b = screens.create_screen(slug, "Scène B", kind="jeu_2d")
zed_id = screens.add_scene_object(slug, scene_a, kind="personnage") zed_id = screens.add_scene_object(slug, scene_a, kind="personnage")
@@ -134,7 +137,9 @@ def test_list_personnage_names_is_alphabetical_and_deduped_across_scenes(tmp_gam
anna_id = screens.add_scene_object(slug, scene_b, kind="personnage") anna_id = screens.add_scene_object(slug, scene_b, kind="personnage")
screens.set_scene_object_name(slug, anna_id, "Anna") screens.set_scene_object_name(slug, anna_id, "Anna")
unnamed_id = screens.add_scene_object(slug, scene_a, kind="personnage") # sans nom, ignoré unnamed_id = screens.add_scene_object(slug, scene_a, kind="personnage") # sans nom, ignoré
decor_id = screens.add_scene_object(slug, scene_a, kind="decor") # pas un personnage, ignoré decor_id = screens.add_scene_object(slug, scene_a, kind="decor") # nommé -> INCLUS (pas réservé au personnage)
screens.set_scene_object_name(slug, decor_id, "Un décor nommé") screens.set_scene_object_name(slug, decor_id, "Un coffre")
dialogue_box_id = screens.add_scene_object(slug, scene_a, kind="dialogue_box") # widget d'UI, jamais un "qui parle"
screens.set_scene_object_name(slug, dialogue_box_id, "Boîte nommée par erreur")
assert screens.list_personnage_names(slug) == ["Anna", "Zed le forgeron"] assert screens.list_named_scene_object_names(slug) == ["Anna", "Un coffre", "Zed le forgeron"]
+1 -1
View File
@@ -75,7 +75,7 @@ def test_delete_quest_removes_it(tmp_game_slug_cleanup):
def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status(): def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status():
"""speaker est un nom de PERSONNAGE libre (voir "ℹ️ Informations", """speaker est un nom de PERSONNAGE libre (voir "ℹ️ Informations",
screens/rendering/personnage_names.py) — "Le Sorcier" est donc un nom 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 valide comme n'importe quel autre, seul un speaker/texte VIDE ou un
élément mal formé est rejeté.""" élément mal formé est rejeté."""
raw = { raw = {