Quête "nouvelle" : écran Accepter/Refuser à la fin du dialogue
Une fois le dialogue épuisé, si la quête est encore "nouvelle", la boîte de dialogue bascule sur un écran d'offre au lieu de se masquer : header "Quête : <titre>", body l'objectif, pied deux boutons. - Accepter -> la quête passe "en_cours" (en MÉMOIRE seulement, gameData.quests — jamais persisté en base : le statut en base est celui de DÉPART pour toute nouvelle partie, pas un état de partie en cours, voir PLAYER_SHARED/full_game_payload.py). - Refuser -> la boîte se referme SANS toucher au statut, qui reste "nouvelle" : la prochaine interaction rejoue exactement le même dialogue depuis le début — impossible d'avancer la quête sans l'accepter un jour. Pour tout autre statut (déjà "en_cours"/"terminee"), la boîte se masque normalement à la fin du dialogue, comme avant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
df34106752
commit
e90d941527
@@ -19,7 +19,10 @@ Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); // pas mocké : la vraie logique métier
|
||||
|
||||
const { forgeCollisionRuleTick } = require('../collision-rules-controller.js');
|
||||
const {
|
||||
forgeCollisionRuleTick,
|
||||
} = require('../collision-rules-controller.js');
|
||||
const { forgeDialogueBoxAcceptQuest, forgeQuestById } = require('../dialogue-box-controller.js');
|
||||
|
||||
function fakeEl(style) {
|
||||
const classes = new Set();
|
||||
@@ -33,13 +36,14 @@ function fakeEl(style) {
|
||||
if (sel === '.dialogueBoxWidget') return children.find(c => c._isDialogueBoxWidget);
|
||||
if (sel === '[data-dialogue-role="header"]') return this._header;
|
||||
if (sel === '[data-dialogue-role="body"]') return this._body;
|
||||
if (sel === '[data-dialogue-role="footer"]') return this._footer;
|
||||
return undefined;
|
||||
},
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; },
|
||||
};
|
||||
}
|
||||
|
||||
test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suivant" avance, se masque à la fin', () => {
|
||||
test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer la quête en_cours', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
|
||||
const mrPinguin = {
|
||||
id: 2, kind: 'personnage',
|
||||
@@ -54,12 +58,13 @@ test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suiva
|
||||
boxWidget._objectId = '3';
|
||||
boxWidget._header = { textContent: '' };
|
||||
boxWidget._body = { textContent: '' };
|
||||
boxWidget._footer = { innerHTML: '' };
|
||||
|
||||
global.window.currentScreenId = 1;
|
||||
global.gameData = {
|
||||
personnage_animations: {},
|
||||
quests: [{
|
||||
id: 99, status: 'nouvelle',
|
||||
id: 99, title: 'Retrouver Charlie', status: 'nouvelle', objectif: 'Parler à Mr. Pinguin',
|
||||
dialogues: {
|
||||
nouvelle: [
|
||||
{ speaker: 'Mr. Pinguin', text: 'Bonjour !' },
|
||||
@@ -102,7 +107,14 @@ test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suiva
|
||||
assert.equal(boxWidget._body.textContent, 'Salut !');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> la modale disparaît
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false);
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> quête "nouvelle" -> écran d'offre, PAS masqué
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
assert.equal(boxWidget._header.textContent, 'Quête : Retrouver Charlie');
|
||||
assert.equal(boxWidget._body.textContent, 'Parler à Mr. Pinguin');
|
||||
assert.match(boxWidget._footer.innerHTML, /Accepter/);
|
||||
|
||||
forgeDialogueBoxAcceptQuest(99, '3'); // clic "Accepter"
|
||||
assert.equal(forgeQuestById(99).status, 'en_cours');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false); // la modale disparaît
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -10,12 +10,14 @@ global.gameData = {};
|
||||
|
||||
const {
|
||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
|
||||
} = require('../dialogue-box-controller.js');
|
||||
|
||||
function fakeWidget(objectId) {
|
||||
const roles = {
|
||||
header: { textContent: '' },
|
||||
body: { textContent: '' },
|
||||
footer: { innerHTML: '' },
|
||||
};
|
||||
const classes = new Set();
|
||||
return {
|
||||
@@ -24,6 +26,7 @@ function fakeWidget(objectId) {
|
||||
querySelector: sel => {
|
||||
if (sel === '[data-dialogue-role="header"]') return roles.header;
|
||||
if (sel === '[data-dialogue-role="body"]') return roles.body;
|
||||
if (sel === '[data-dialogue-role="footer"]') return roles.footer;
|
||||
return null;
|
||||
},
|
||||
_roles: roles, _classes: classes,
|
||||
@@ -34,8 +37,9 @@ test.beforeEach(() => {
|
||||
global.window.currentScreenId = 1;
|
||||
global.gameData = {
|
||||
quests: [
|
||||
{ id: 5, status: 'nouvelle', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } },
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'nouvelle', objectif: 'Parler au sorcier', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } },
|
||||
{ id: 6, status: 'en_cours', dialogues: { nouvelle: [{ speaker: 'PNJ', text: 'x' }], en_cours: [], terminee: [] } },
|
||||
{ id: 8, status: 'terminee', dialogues: { nouvelle: [], en_cours: [], terminee: [{ speaker: 'PNJ', text: 'Merci !' }] } },
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -53,11 +57,28 @@ test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête sans réplique pour son statut actuel : aucun effet', () => {
|
||||
test('forgeShowDialogueBox — quête sans réplique pour son statut "en_cours" (pas "nouvelle") : aucun effet', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox(6)); // "en_cours" -> dialogues.en_cours est vide
|
||||
forgeShowDialogueBox(6); // "en_cours" -> dialogues.en_cours est vide, et statut != "nouvelle"
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête "nouvelle" SANS réplique : propose directement l\'offre (accepter/refuser)', () => {
|
||||
global.gameData.quests.push({ id: 7, title: 'Le trésor perdu', status: 'nouvelle', objectif: 'Trouver le trésor', dialogues: {} });
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(7);
|
||||
assert.equal(widget._roles.header.textContent, 'Quête : Le trésor perdu');
|
||||
assert.equal(widget._roles.body.textContent, 'Trouver le trésor');
|
||||
assert.match(widget._roles.footer.innerHTML, /Accepter/);
|
||||
assert.match(widget._roles.footer.innerHTML, /Refuser/);
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -69,22 +90,59 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => {
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque (quête pas "nouvelle")', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
try {
|
||||
forgeShowDialogueBox(8); // statut "terminee" -> pas d'offre à la fin, se masque normalement
|
||||
assert.equal(widget._roles.header.textContent, 'PNJ');
|
||||
assert.equal(widget._roles.body.textContent, 'Merci !');
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
|
||||
forgeDialogueBoxAdvance(42); // dernière (et unique) réplique consommée -> se masque
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('quête "nouvelle" : le dialogue épuisé bascule sur l\'offre (Accepter/Refuser) au lieu de se masquer', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
assert.equal(widget._roles.header.textContent, 'Le Sorcier');
|
||||
assert.equal(widget._roles.body.textContent, 'Bonjour !');
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
|
||||
forgeDialogueBoxAdvance(42);
|
||||
assert.equal(widget._roles.header.textContent, 'Joueur');
|
||||
assert.equal(widget._roles.body.textContent, 'Salut.');
|
||||
assert.equal(widget._classes.has('is-active'), true); // encore une réplique visible
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> écran d'offre, PAS masqué
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
assert.equal(widget._roles.header.textContent, 'Quête : La quête du sorcier');
|
||||
assert.equal(widget._roles.body.textContent, 'Parler au sorcier');
|
||||
assert.match(widget._roles.footer.innerHTML, /Accepter/);
|
||||
assert.match(widget._roles.footer.innerHTML, /Refuser/);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> se masque
|
||||
test('forgeDialogueBoxAcceptQuest — fait passer la quête "en_cours" (en mémoire seulement) et masque la boîte', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
widget._classes.add('is-active');
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
assert.equal(forgeQuestById(5).status, 'en_cours');
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxDeclineQuest — masque la boîte SANS changer le statut (reste "nouvelle")', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
widget._classes.add('is-active');
|
||||
forgeDialogueBoxDeclineQuest(42);
|
||||
assert.equal(forgeQuestById(5).status, 'nouvelle'); // inchangé -> le dialogue rejouera depuis le début
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -5,10 +5,21 @@
|
||||
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
||||
// "quete", affiche la réplique en cours du dialogue correspondant au
|
||||
// STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py),
|
||||
// avance d'une réplique à chaque clic sur "Suivant", se masque à la fin.
|
||||
// Silencieux (aucun effet) si la quête est introuvable, n'a aucune
|
||||
// réplique pour son statut, ou si l'auteur n'a posé aucun widget sur
|
||||
// l'écran — même esprit que le reste du moteur (jamais planter).
|
||||
// avance d'une réplique à chaque clic sur "Suivant".
|
||||
//
|
||||
// Une fois le dialogue épuisé : si la quête est encore "nouvelle", la
|
||||
// boîte bascule en écran d'OFFRE ("Quête : <titre>" / objectif / boutons
|
||||
// Accepter-Refuser) au lieu de se masquer — accepter fait passer la
|
||||
// quête "en_cours" (LOCAL à cette partie, jamais persisté en base :
|
||||
// gameData.quests n'est qu'un instantané par joueur, voir
|
||||
// full_game_payload.py ; muter la ligne en base changerait le statut de
|
||||
// DÉPART de la quête pour toutes les parties futures) ; refuser ferme
|
||||
// simplement la boîte SANS changer le statut — la quête reste "nouvelle",
|
||||
// donc la prochaine interaction rejoue EXACTEMENT le même dialogue depuis
|
||||
// le début (impossible d'avancer sans accepter). Pour tout autre statut
|
||||
// (déjà "en_cours"/"terminee"), la boîte se masque normalement à la fin.
|
||||
// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a
|
||||
// posé aucun widget sur l'écran — même esprit que le reste du moteur.
|
||||
|
||||
var forgeDialogueBoxState = {};
|
||||
|
||||
@@ -24,6 +35,10 @@ function forgeDialogueBoxWidgetById(objectId) {
|
||||
return document.querySelector('.dialogueBoxWidget[data-object-id="' + objectId + '"]');
|
||||
}
|
||||
|
||||
function forgeDialogueBoxFooterEl(widget) {
|
||||
return widget.querySelector('[data-dialogue-role="footer"]');
|
||||
}
|
||||
|
||||
function forgeRenderDialogueBoxLine(widget, line) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
@@ -31,6 +46,15 @@ function forgeRenderDialogueBoxLine(widget, line) {
|
||||
if (body) body.textContent = line.text;
|
||||
}
|
||||
|
||||
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
|
||||
// "Suivant →", voir render_scene_object.py) — nécessaire pour effacer
|
||||
// les boutons Accepter/Refuser d'une offre PRÉCÉDENTE avant de rejouer
|
||||
// un nouveau dialogue dans la même boîte.
|
||||
function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
||||
}
|
||||
|
||||
// Appelée par l'action "quete" (collision-rules-controller.js) — trouve
|
||||
// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois,
|
||||
// comme le reste du moteur 2D).
|
||||
@@ -38,11 +62,18 @@ function forgeShowDialogueBox(questId) {
|
||||
var quest = forgeQuestById(questId);
|
||||
if (!quest) return;
|
||||
var lines = (quest.dialogues && quest.dialogues[quest.status]) || [];
|
||||
if (!lines.length) return;
|
||||
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
forgeDialogueBoxState[objectId] = { lines: lines, index: 0 };
|
||||
// Aucune réplique posée pour ce statut : une quête "nouvelle" propose
|
||||
// quand même directement l'offre (accepter/refuser), les autres
|
||||
// statuts restent silencieux (rien à montrer).
|
||||
if (!lines.length) {
|
||||
if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest);
|
||||
return;
|
||||
}
|
||||
forgeResetDialogueBoxFooter(widget, objectId);
|
||||
forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 };
|
||||
forgeRenderDialogueBoxLine(widget, lines[0]);
|
||||
widget.classList.add('is-active');
|
||||
}
|
||||
@@ -55,6 +86,11 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
if (!state || !widget) return;
|
||||
state.index += 1;
|
||||
if (state.index >= state.lines.length) {
|
||||
var quest = forgeQuestById(state.questId);
|
||||
if (quest && quest.status === 'nouvelle') {
|
||||
forgeShowQuestOffer(widget, objectId, quest);
|
||||
return;
|
||||
}
|
||||
widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
return;
|
||||
@@ -62,11 +98,50 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
forgeRenderDialogueBoxLine(widget, state.lines[state.index]);
|
||||
}
|
||||
|
||||
// Écran d'offre : header = "Quête : <titre>", body = l'objectif, pied =
|
||||
// Accepter/Refuser au lieu du "Suivant" habituel.
|
||||
function forgeShowQuestOffer(widget, objectId, quest) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (header) header.textContent = 'Quête : ' + quest.title;
|
||||
if (body) body.textContent = quest.objectif || '';
|
||||
if (footer) {
|
||||
footer.innerHTML =
|
||||
'<button type="button" class="dialogueBoxNextBtn dialogueBoxAcceptBtn" onclick="forgeDialogueBoxAcceptQuest(' + quest.id + ', ' + objectId + ')">Accepter</button>' +
|
||||
'<button type="button" class="dialogueBoxNextBtn dialogueBoxDeclineBtn" onclick="forgeDialogueBoxDeclineQuest(' + objectId + ')">Refuser</button>';
|
||||
}
|
||||
forgeDialogueBoxState[objectId] = { questId: quest.id, lines: [], index: 0 };
|
||||
widget.classList.add('is-active');
|
||||
}
|
||||
|
||||
// Fait passer la quête "en_cours" — UNIQUEMENT dans l'instantané en
|
||||
// mémoire (gameData.quests), jamais persisté en base : voir la note en
|
||||
// tête de fichier (le statut en base est celui de DÉPART pour toute
|
||||
// nouvelle partie, pas un état de partie en cours).
|
||||
function forgeDialogueBoxAcceptQuest(questId, objectId) {
|
||||
var quest = forgeQuestById(questId);
|
||||
if (quest) quest.status = 'en_cours';
|
||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
}
|
||||
|
||||
// Referme la boîte SANS toucher au statut de la quête (reste "nouvelle")
|
||||
// — la prochaine interaction rejoue donc le même dialogue depuis le
|
||||
// début : impossible d'avancer la quête sans un jour l'accepter.
|
||||
function forgeDialogueBoxDeclineQuest(objectId) {
|
||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que les autres
|
||||
// contrôleurs de ce dossier.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
|
||||
};
|
||||
}
|
||||
|
||||
+9
-1
@@ -653,12 +653,20 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.playScreen .dialogueBoxWidget.is-active{ display:flex; }
|
||||
.dialogueBoxHeader{ padding:8px 12px; font-weight:700; }
|
||||
.dialogueBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; }
|
||||
.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; }
|
||||
.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; gap:8px; }
|
||||
.dialogueBoxNextBtn{
|
||||
background:rgba(255,255,255,.15); color:inherit; border:1px solid rgba(255,255,255,.3);
|
||||
border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer;
|
||||
}
|
||||
.dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); }
|
||||
/* Écran d'offre de quête (voir forgeShowQuestOffer, dialogue-box-controller.js) :
|
||||
Accepter/Refuser remplacent le "Suivant" habituel dans le pied de la
|
||||
boîte — verts/rouges pour rester lisibles sur n'importe quelle couleur
|
||||
de pied choisie par l'auteur (voir "🎨 Style"). */
|
||||
.dialogueBoxAcceptBtn{ background:#2ecc71; border-color:#2ecc71; color:#0b1a10; font-weight:700; }
|
||||
.dialogueBoxAcceptBtn:hover{ background:#3ddb80; }
|
||||
.dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; }
|
||||
.dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); }
|
||||
|
||||
/* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur
|
||||
sa propre balise (voir applySelectionHighlight) — seul moyen de la voir
|
||||
|
||||
Reference in New Issue
Block a user