Dev #10
@@ -78,6 +78,17 @@ from .custom_events.get_custom_event import get_custom_event
|
||||
from .custom_events.create_custom_event import create_custom_event
|
||||
from .custom_events.update_custom_event import update_custom_event
|
||||
|
||||
from .quests.constants import (
|
||||
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS, QUEST_SPEAKER_CHOICES,
|
||||
)
|
||||
from .quests.list_quests import list_quests
|
||||
from .quests.get_quest import get_quest
|
||||
from .quests.create_quest import create_quest
|
||||
from .quests.update_quest import update_quest
|
||||
from .quests.delete_quest import delete_quest
|
||||
from .quests.set_quest_dialogues import set_quest_dialogues
|
||||
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues
|
||||
|
||||
__all__ = [
|
||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
||||
"SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status",
|
||||
@@ -95,4 +106,7 @@ __all__ = [
|
||||
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
||||
"delete_global_variable_by_id",
|
||||
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
||||
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "QUEST_SPEAKER_CHOICES",
|
||||
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
|
||||
"set_quest_dialogues", "sanitize_quest_dialogues",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"]
|
||||
QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"}
|
||||
QUEST_RESULT_CHOICES = ["reussi", "echoue"]
|
||||
QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"}
|
||||
QUEST_SPEAKER_CHOICES = ["joueur", "pnj"]
|
||||
@@ -0,0 +1,18 @@
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
from .get_quest import get_quest
|
||||
|
||||
|
||||
def create_quest(slug, title="Nouvelle quête"):
|
||||
"""Titre par défaut plutôt qu'un refus — créée d'un clic depuis la
|
||||
liste (voir templates/quests.html) ou depuis l'assistant "+ Action"
|
||||
de l'éditeur de collision (voir collision-rules-editor.js), avant
|
||||
même que l'auteur ait choisi un titre."""
|
||||
ensure_quests_schema(slug)
|
||||
title = (title or "").strip() or "Nouvelle quête"
|
||||
conn = connect(slug)
|
||||
conn.execute("INSERT INTO _quests (title) VALUES (?)", (title,))
|
||||
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return get_quest(slug, new_id)
|
||||
@@ -0,0 +1,10 @@
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
|
||||
|
||||
def delete_quest(slug, quest_id):
|
||||
ensure_quests_schema(slug)
|
||||
conn = connect(slug)
|
||||
conn.execute("DELETE FROM _quests WHERE id = ?", (quest_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,29 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def ensure_quests_schema(slug):
|
||||
"""Migration légère (même principe que ensure_custom_events_schema.py) :
|
||||
crée _quests si absente. Une quête vit pour TOUT le jeu (pas par écran),
|
||||
référencée par id technique depuis l'action "quete" d'une règle de
|
||||
collision (voir screens/rendering/collision_rules.py, quete_id). Les
|
||||
dialogues (voir static/js/quests/quest-editor.js — 3 colonnes
|
||||
nouvelle/en_cours/terminee) sont stockés en JSON plutôt qu'une table à
|
||||
part : jamais interrogés/filtrés côté SQL, toujours lus/réécrits en
|
||||
bloc pour UNE quête à la fois (même convention que _scene_objects.attributes)."""
|
||||
conn = connect(slug)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS _quests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
objectif TEXT DEFAULT '',
|
||||
recompense_score INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'nouvelle',
|
||||
resultat TEXT,
|
||||
dialogues_json TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,11 @@
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
from .list_quests import _row_to_quest
|
||||
|
||||
|
||||
def get_quest(slug, quest_id):
|
||||
ensure_quests_schema(slug)
|
||||
conn = connect(slug)
|
||||
row = conn.execute("SELECT * FROM _quests WHERE id = ?", (quest_id,)).fetchone()
|
||||
conn.close()
|
||||
return _row_to_quest(row) if row else None
|
||||
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
|
||||
|
||||
def _row_to_quest(row):
|
||||
quest = dict(row)
|
||||
try:
|
||||
quest["dialogues"] = json.loads(quest.pop("dialogues_json") or "{}")
|
||||
except (TypeError, ValueError):
|
||||
quest["dialogues"] = {}
|
||||
quest.pop("dialogues_json", None)
|
||||
return quest
|
||||
|
||||
|
||||
def list_quests(slug):
|
||||
ensure_quests_schema(slug)
|
||||
conn = connect(slug)
|
||||
rows = conn.execute("SELECT * FROM _quests ORDER BY created_at DESC, id DESC").fetchall()
|
||||
conn.close()
|
||||
return [_row_to_quest(r) for r in rows]
|
||||
@@ -0,0 +1,30 @@
|
||||
from .constants import QUEST_STATUS_CHOICES, QUEST_SPEAKER_CHOICES
|
||||
|
||||
_MAX_LINES_PER_COLUMN = 200
|
||||
|
||||
|
||||
def sanitize_quest_dialogues(raw_dialogues):
|
||||
"""Une réplique = {speaker: 'joueur'|'pnj', text: '...'} — une colonne
|
||||
par statut de quête (voir QUEST_STATUS_CHOICES), la même forme que
|
||||
resolve_collision_rules.sanitize_collision_rules : rejette tout
|
||||
élément invalide plutôt que de lever, ne garde jamais un texte vide."""
|
||||
result = {status: [] for status in QUEST_STATUS_CHOICES}
|
||||
if not isinstance(raw_dialogues, dict):
|
||||
return result
|
||||
for status in QUEST_STATUS_CHOICES:
|
||||
raw_lines = raw_dialogues.get(status)
|
||||
if not isinstance(raw_lines, list):
|
||||
continue
|
||||
cleaned = []
|
||||
for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]:
|
||||
if not isinstance(raw_line, dict):
|
||||
continue
|
||||
speaker = raw_line.get("speaker")
|
||||
text = raw_line.get("text")
|
||||
if speaker not in QUEST_SPEAKER_CHOICES:
|
||||
continue
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
continue
|
||||
cleaned.append({"speaker": speaker, "text": text.strip()})
|
||||
result[status] = cleaned
|
||||
return result
|
||||
@@ -0,0 +1,21 @@
|
||||
import json
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
from .get_quest import get_quest
|
||||
from .sanitize_quest_dialogues import sanitize_quest_dialogues
|
||||
|
||||
|
||||
def set_quest_dialogues(slug, quest_id, raw_dialogues):
|
||||
"""Toujours les 3 colonnes ENTIÈRES (nouvelle/en_cours/terminee) en un
|
||||
bloc — même convention "replace-all" que set_scene_object_collision_rules.py,
|
||||
jamais une réplique isolée."""
|
||||
ensure_quests_schema(slug)
|
||||
if get_quest(slug, quest_id) is None:
|
||||
return None
|
||||
dialogues = sanitize_quest_dialogues(raw_dialogues)
|
||||
conn = connect(slug)
|
||||
conn.execute("UPDATE _quests SET dialogues_json = ? WHERE id = ?", (json.dumps(dialogues), quest_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return dialogues
|
||||
@@ -0,0 +1,42 @@
|
||||
from ..connection import connect
|
||||
from .constants import QUEST_STATUS_CHOICES, QUEST_RESULT_CHOICES
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
from .get_quest import get_quest
|
||||
|
||||
|
||||
def update_quest(slug, quest_id, fields):
|
||||
"""Met à jour uniquement les clés reconnues et valides de `fields` —
|
||||
ignore tout le reste plutôt que de lever, même esprit que
|
||||
sanitize_collision_rules (jamais confiance dans ce qu'envoie le
|
||||
client). title vide => ignoré (garde l'ancien plutôt qu'un titre vide)."""
|
||||
ensure_quests_schema(slug)
|
||||
if get_quest(slug, quest_id) is None:
|
||||
return None
|
||||
updates = {}
|
||||
if "title" in fields:
|
||||
title = (fields["title"] or "").strip()
|
||||
if title:
|
||||
updates["title"] = title
|
||||
if "objectif" in fields:
|
||||
updates["objectif"] = (fields["objectif"] or "").strip()
|
||||
if "recompense_score" in fields:
|
||||
try:
|
||||
updates["recompense_score"] = int(fields["recompense_score"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "status" in fields and fields["status"] in QUEST_STATUS_CHOICES:
|
||||
updates["status"] = fields["status"]
|
||||
if "resultat" in fields:
|
||||
resultat = fields["resultat"]
|
||||
if resultat in QUEST_RESULT_CHOICES:
|
||||
updates["resultat"] = resultat
|
||||
elif resultat is None:
|
||||
updates["resultat"] = None
|
||||
if not updates:
|
||||
return get_quest(slug, quest_id)
|
||||
conn = connect(slug)
|
||||
set_clause = ", ".join(f"{key} = ?" for key in updates)
|
||||
conn.execute(f"UPDATE _quests SET {set_clause} WHERE id = ?", (*updates.values(), quest_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return get_quest(slug, quest_id)
|
||||
+1
-1
@@ -1 +1 @@
|
||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding
|
||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import quests_view, quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues
|
||||
@@ -0,0 +1,11 @@
|
||||
from flask import jsonify
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/<int:quest_id>/delete", methods=["POST"])
|
||||
def quest_delete(slug, quest_id):
|
||||
db.delete_quest(slug, quest_id)
|
||||
return jsonify({"ok": True})
|
||||
@@ -0,0 +1,17 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/<int:quest_id>/dialogues", methods=["POST"])
|
||||
def quest_dialogues(slug, quest_id):
|
||||
"""Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) —
|
||||
le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet
|
||||
complet à chaque modification plutôt qu'une réplique isolée."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues"))
|
||||
if dialogues is None:
|
||||
return jsonify({"error": "quête introuvable"}), 404
|
||||
return jsonify({"dialogues": dialogues})
|
||||
@@ -0,0 +1,12 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/new", methods=["POST"])
|
||||
def quest_new(slug):
|
||||
body = request.get_json(silent=True) or {}
|
||||
quest = db.create_quest(slug, body.get("title"))
|
||||
return jsonify({"quest": quest})
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/<int:quest_id>", methods=["POST"])
|
||||
def quest_update(slug, quest_id):
|
||||
body = request.get_json(silent=True) or {}
|
||||
quest = db.update_quest(slug, quest_id, body)
|
||||
if quest is None:
|
||||
return jsonify({"error": "quête introuvable"}), 404
|
||||
return jsonify({"quest": quest})
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import jsonify
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/list.json")
|
||||
def quests_list_json(slug):
|
||||
"""Utilisé par l'assistant "+ Action" de l'éditeur de collision (voir
|
||||
collision-rules-editor.js) pour lister les quêtes existantes sans
|
||||
quitter la modale déjà ouverte — même contenu que quests_view, en JSON
|
||||
seul plutôt qu'une page complète."""
|
||||
return jsonify({"quests": db.list_quests(slug)})
|
||||
@@ -0,0 +1,20 @@
|
||||
import json
|
||||
|
||||
from flask import render_template
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests")
|
||||
def quests_view(slug):
|
||||
"""Éditeur de quêtes (voir db/quests/) — page dédiée game-wide, dans le
|
||||
même esprit que game_dashboard.html : une liste de cartes + un bouton
|
||||
"+ Nouvelle quête" au-dessus, chaque carte ouvrant une modale avec
|
||||
l'arbre de dialogue (static/js/quests/quest-editor.js — même module
|
||||
JS que celui rendu depuis l'assistant "+ Action" de l'éditeur de
|
||||
collision, voir collision-rules-editor.js)."""
|
||||
game = db.game_meta(slug)
|
||||
quests = db.list_quests(slug)
|
||||
return render_template("quests.html", game=game, quests_json=json.dumps(quests))
|
||||
@@ -128,4 +128,10 @@ def render_scene_edit(slug, screen_id, game):
|
||||
collision_rule_objects_json=json.dumps(collision_rule_objects),
|
||||
collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES,
|
||||
collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX,
|
||||
# Éditeur de quêtes (voir db/quests/) : préchargées ici pour que
|
||||
# collisionRuleActionSummary (collision-rules-editor.js) affiche
|
||||
# tout de suite le TITRE de la quête référencée par une règle
|
||||
# existante, sans attendre l'ouverture de l'assistant "+ Action"
|
||||
# (seul moment où questPickerHtml irait la chercher sinon).
|
||||
quests_json=json.dumps(db.list_quests(slug)),
|
||||
)
|
||||
|
||||
@@ -11,9 +11,13 @@ import json
|
||||
# joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet).
|
||||
#
|
||||
# Action (une seule par règle) :
|
||||
# - "quete" : quete_id (référence libre — l'éditeur de quête lui-même
|
||||
# reste à construire, cette action ne fait QUE mémoriser
|
||||
# la référence pour l'instant).
|
||||
# - "quete" : quete_id (id technique d'une quête — voir db/quests/,
|
||||
# choisie/créée via l'assistant "+ Action" qui ouvre
|
||||
# directement l'éditeur de quête, static/js/quests/
|
||||
# quest-editor.js) — une quête inexistante/supprimée
|
||||
# depuis reste une référence orpheline plutôt qu'une
|
||||
# contrainte SQL, comme le reste de ce moteur (voir
|
||||
# evenement_id ci-dessous, même principe).
|
||||
# - "attaque" : joue la pose "attack" du personnage si disponible (voir
|
||||
# personnage-controller.js) — pas de système de combat
|
||||
# (dégâts/PV...) pour l'instant, effet visuel seul.
|
||||
@@ -45,10 +49,10 @@ def _sanitize_action(raw_action, depth=1):
|
||||
return None # un seul niveau d'imbrication autorisé
|
||||
action = {"type": action_type}
|
||||
if action_type == "quete":
|
||||
quete_id = raw_action.get("quete_id")
|
||||
if not (isinstance(quete_id, str) and quete_id.strip()):
|
||||
try:
|
||||
action["quete_id"] = int(raw_action.get("quete_id"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
action["quete_id"] = quete_id.strip()
|
||||
elif action_type == "evenement":
|
||||
try:
|
||||
action["evenement_id"] = int(raw_action.get("evenement_id"))
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// ---------- Éditeur de quêtes ----------
|
||||
// Module partagé entre templates/quests.html (page dédiée : liste de
|
||||
// cartes + "+ Nouvelle quête") et static/js/scenes/collision-rules-editor.js
|
||||
// (l'assistant "+ Action" de l'éditeur de collision ouvre CETTE MÊME
|
||||
// modale de dialogue quand l'auteur choisit "Déclencher une quête",
|
||||
// plutôt qu'un simple champ texte pour un id). La modale (arbre de
|
||||
// dialogue) est créée dynamiquement (ensureQuestDialogueModal) plutôt que
|
||||
// dupliquée dans deux templates.
|
||||
//
|
||||
// Une quête (voir db/quests/) : id, title, objectif, recompense_score,
|
||||
// status (nouvelle/en_cours/terminee — statut COURANT, informatif),
|
||||
// resultat (reussi/echoue), dialogues (une liste de répliques PAR
|
||||
// STATUT — 3 colonnes dans l'éditeur : quel dialogue jouer selon l'état
|
||||
// de la quête au moment où le joueur parle au PNJ).
|
||||
|
||||
var QUEST_STATUS_LABELS = { nouvelle: 'Nouvelle', en_cours: 'En cours', terminee: 'Terminée' };
|
||||
var QUEST_RESULT_LABELS = { reussi: '✅ Réussi', echoue: '❌ Échoué' };
|
||||
var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee'];
|
||||
|
||||
// Quêtes connues côté client — préremplies par QUESTS_JSON (quests.html)
|
||||
// si présent, sinon vide tant qu'on n'a pas encore fetch (contexte
|
||||
// éditeur de collision, voir questPickerEnsureLoaded).
|
||||
var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : [];
|
||||
|
||||
function questById(questId) {
|
||||
return QUESTS.find(function (q) { return q.id === questId; });
|
||||
}
|
||||
|
||||
function questCardHtml(quest, i) {
|
||||
var resultatHtml = quest.resultat ? '<span class="questPill questPill-resultat">' + QUEST_RESULT_LABELS[quest.resultat] + '</span>' : '';
|
||||
return (
|
||||
'<div class="questCard" style="animation-delay:' + (i * 50) + 'ms" onclick="openQuestDialogueModal(' + quest.id + ')">' +
|
||||
'<div class="questCardHeader">' +
|
||||
'<h3 class="questCardTitle">' + (quest.title || '').replace(/</g, '<') + '</h3>' +
|
||||
'<span class="questPill questPill-status questPill-status-' + quest.status + '">' + QUEST_STATUS_LABELS[quest.status] + '</span>' +
|
||||
'</div>' +
|
||||
'<p class="questCardObjectif">' + (quest.objectif ? quest.objectif.replace(/</g, '<') : '<span class="hint">Aucun objectif défini.</span>') + '</p>' +
|
||||
'<div class="questCardFooter">' +
|
||||
'<span class="questPill questPill-score">🏆 +' + (quest.recompense_score || 0) + '</span>' +
|
||||
resultatHtml +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderQuestListPage() {
|
||||
var container = document.getElementById('questsGrid');
|
||||
if (!container) return;
|
||||
if (!QUESTS.length) {
|
||||
container.innerHTML = '<p class="hint">Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = QUESTS.map(questCardHtml).join('');
|
||||
}
|
||||
|
||||
function createQuestFromListPage() {
|
||||
fetch('/game/' + GAME_SLUG + '/quests/new', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
QUESTS.unshift(json.quest);
|
||||
renderQuestListPage();
|
||||
openQuestDialogueModal(json.quest.id);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Modale "arbre de dialogue" ----------
|
||||
// Créée une seule fois (au premier appel), réutilisée ensuite — partagée
|
||||
// entre la page dédiée et l'assistant "+ Action" de l'éditeur de
|
||||
// collision, jamais dupliquée dans les templates.
|
||||
var _questModalOnUse = null;
|
||||
|
||||
function ensureQuestDialogueModal() {
|
||||
var modal = document.getElementById('questDialogueModal');
|
||||
if (modal) return modal;
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'modal';
|
||||
modal.id = 'questDialogueModal';
|
||||
modal.innerHTML =
|
||||
'<div class="modal-background" onclick="closeQuestDialogueModal()"></div>' +
|
||||
'<div class="modal-content questDialogueModalContent"><div class="box">' +
|
||||
'<div id="questDialogueModalBody"></div>' +
|
||||
'</div></div>';
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
// `onUse(quest)` optionnel : posé par collisionWizardChooseAction('quete', ...)
|
||||
// pour finaliser la règle de collision avec cette quête, affiche alors un
|
||||
// bouton "✓ Utiliser cette quête" épinglé en haut de la modale.
|
||||
function openQuestDialogueModal(questId, onUse) {
|
||||
_questModalOnUse = onUse || null;
|
||||
var modal = ensureQuestDialogueModal();
|
||||
modal.classList.add('is-active');
|
||||
renderQuestDialogueModalBody(questId);
|
||||
}
|
||||
|
||||
function closeQuestDialogueModal() {
|
||||
var modal = document.getElementById('questDialogueModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
_questModalOnUse = null;
|
||||
_questModalCurrentId = null;
|
||||
}
|
||||
|
||||
function questUseThisQuest(questId) {
|
||||
var quest = questById(questId);
|
||||
if (_questModalOnUse && quest) _questModalOnUse(quest);
|
||||
closeQuestDialogueModal();
|
||||
}
|
||||
|
||||
function questFieldRowHtml(quest) {
|
||||
return (
|
||||
'<div class="questFieldsGrid">' +
|
||||
'<label class="questField"><span>Titre</span><input type="text" class="input" value="' + (quest.title || '').replace(/"/g, '"') + '" onchange="questUpdateField(' + quest.id + ", 'title', this.value)\"></label>" +
|
||||
'<label class="questField questField-wide"><span>Objectif</span><input type="text" class="input" value="' + (quest.objectif || '').replace(/"/g, '"') + '" onchange="questUpdateField(' + quest.id + ", 'objectif', this.value)\"></label>" +
|
||||
'<label class="questField"><span>Récompense (score)</span><input type="number" class="input" value="' + (quest.recompense_score || 0) + '" onchange="questUpdateField(' + quest.id + ", 'recompense_score', this.value)\"></label>" +
|
||||
'<label class="questField"><span>Statut</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'status', this.value)\">" +
|
||||
QUEST_STATUS_ORDER.map(function (s) { return '<option value="' + s + '"' + (quest.status === s ? ' selected' : '') + '>' + QUEST_STATUS_LABELS[s] + '</option>'; }).join('') +
|
||||
'</select></label>' +
|
||||
'<label class="questField"><span>Résultat</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'resultat', this.value)\">" +
|
||||
'<option value=""' + (!quest.resultat ? ' selected' : '') + '>—</option>' +
|
||||
'<option value="reussi"' + (quest.resultat === 'reussi' ? ' selected' : '') + '>Réussi</option>' +
|
||||
'<option value="echoue"' + (quest.resultat === 'echoue' ? ' selected' : '') + '>Échoué</option>' +
|
||||
'</select></label>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function questDialogueBubbleHtml(line, status, index) {
|
||||
return (
|
||||
'<div class="questBubbleRow questBubbleRow-' + line.speaker + '">' +
|
||||
'<div class="questBubble questBubble-' + line.speaker + '">' +
|
||||
'<div class="questBubbleSpeaker" onclick="questToggleSpeaker(\'' + status + '\', ' + index + ')" title="Changer qui parle">' +
|
||||
(line.speaker === 'joueur' ? '🧑 Joueur' : '🗨️ PNJ') +
|
||||
'</div>' +
|
||||
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
||||
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
||||
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function questDialogueColumnHtml(quest, status) {
|
||||
var lines = (quest.dialogues && quest.dialogues[status]) || [];
|
||||
var bubblesHtml = lines.map(function (line, i) { return questDialogueBubbleHtml(line, status, i); }).join('');
|
||||
return (
|
||||
'<div class="questDialogueColumn">' +
|
||||
'<h4 class="questDialogueColumnTitle">' + QUEST_STATUS_LABELS[status] + '</h4>' +
|
||||
'<div class="questDialogueColumnBubbles" id="questDialogueColumn-' + status + '">' + bubblesHtml + '</div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="questAddBubble(\'' + status + '\')">✚ Réplique</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
var _questModalCurrentId = null;
|
||||
|
||||
function renderQuestDialogueModalBody(questId) {
|
||||
var quest = questById(questId);
|
||||
var body = document.getElementById('questDialogueModalBody');
|
||||
if (!quest || !body) return;
|
||||
_questModalCurrentId = questId;
|
||||
var useBtn = _questModalOnUse
|
||||
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">✓ Utiliser cette quête</button>'
|
||||
: '';
|
||||
body.innerHTML =
|
||||
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
|
||||
'<h2 class="title is-5" style="margin:0;">🗺️ ' + (quest.title || '').replace(/</g, '<') + '</h2>' +
|
||||
'<div style="display:flex; gap:8px; align-items:center;">' + useBtn +
|
||||
'<button type="button" class="button is-small" onclick="closeQuestDialogueModal()">✕</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
questFieldRowHtml(quest) +
|
||||
'<p class="hint mt-2 mb-2">Dialogue joué selon l\'état de la quête au moment où le joueur parle au PNJ :</p>' +
|
||||
'<div class="questDialogueColumns">' + QUEST_STATUS_ORDER.map(function (s) { return questDialogueColumnHtml(quest, s); }).join('') + '</div>';
|
||||
}
|
||||
|
||||
function questUpdateField(questId, field, value) {
|
||||
var quest = questById(questId);
|
||||
if (!quest) return;
|
||||
var body = {};
|
||||
body[field] = field === 'recompense_score' ? Number(value) : (value || null);
|
||||
fetch('/game/' + GAME_SLUG + '/quests/' + questId, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
if (!json.quest) return;
|
||||
Object.assign(quest, json.quest);
|
||||
renderQuestListPage();
|
||||
if (document.getElementById('questDialogueModal') && document.getElementById('questDialogueModal').classList.contains('is-active')) {
|
||||
renderQuestDialogueModalBody(questId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Dialogues : toujours les 3 colonnes ENTIÈRES postées ensemble (voir
|
||||
// db/quests/set_quest_dialogues.py) — jamais une réplique isolée.
|
||||
function questSaveDialogues(quest) {
|
||||
fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/dialogues', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dialogues: quest.dialogues }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) { if (json.dialogues) quest.dialogues = json.dialogues; });
|
||||
}
|
||||
|
||||
function questAddBubble(status) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues = quest.dialogues || {};
|
||||
var lines = quest.dialogues[status] || (quest.dialogues[status] = []);
|
||||
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'pnj';
|
||||
lines.push({ speaker: lastSpeaker === 'pnj' ? 'joueur' : 'pnj', text: '' });
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questToggleSpeaker(status, index) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
var line = quest.dialogues[status][index];
|
||||
line.speaker = line.speaker === 'joueur' ? 'pnj' : 'joueur';
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questUpdateBubbleText(status, index, text) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].text = text;
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questDeleteBubble(status, index) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status].splice(index, 1);
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function _currentQuestModalId() {
|
||||
return _questModalCurrentId;
|
||||
}
|
||||
|
||||
// ---------- Sélecteur de quête (assistant "+ Action" de l'éditeur de collision) ----------
|
||||
// Récupère la liste à jour (jamais présente d'avance dans scene_edit.html,
|
||||
// contrairement à quests.html) puis affiche des cartes + "+ Nouvelle
|
||||
// quête" DANS la modale de collision déjà ouverte (voir
|
||||
// renderCollisionWizardActionStep) ; choisir/créer une quête ouvre
|
||||
// ENSUITE la modale de dialogue (celle-ci empilée par-dessus), avec un
|
||||
// bouton "✓ Utiliser cette quête" qui referme les deux et finalise la
|
||||
// règle — exactement le comportement demandé : cliquer "Déclencher une
|
||||
// quête" ouvre l'éditeur de quête, pas un simple champ texte.
|
||||
function questPickerHtml(quests) {
|
||||
if (!quests.length) {
|
||||
return '<p class="hint mb-2">Aucune quête pour l\'instant.</p>';
|
||||
}
|
||||
return '<div class="collisionWizardChoices">' + quests.map(function (q, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="questPickerChoose(' + q.id + ')">' +
|
||||
'<span class="emoji">🗺️</span><span>' + (q.title || '').replace(/</g, '<') + '</span>' +
|
||||
'</button>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
var _questPickerOnUse = null;
|
||||
|
||||
function renderQuestPickerStep(onUse) {
|
||||
_questPickerOnUse = onUse;
|
||||
var body = collisionWizardBodyEl();
|
||||
body.innerHTML = '<p class="hint mb-2">Quelle quête déclencher ?</p><div id="questPickerList">Chargement…</div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn mt-2" onclick="questPickerCreate()">✚ Nouvelle quête</button>';
|
||||
fetch('/game/' + GAME_SLUG + '/quests/list.json')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
QUESTS = json.quests;
|
||||
var list = document.getElementById('questPickerList');
|
||||
if (list) list.innerHTML = questPickerHtml(QUESTS);
|
||||
});
|
||||
}
|
||||
|
||||
function questPickerChoose(questId) {
|
||||
openQuestDialogueModal(questId, _questPickerOnUse);
|
||||
}
|
||||
|
||||
function questPickerCreate() {
|
||||
fetch('/game/' + GAME_SLUG + '/quests/new', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
QUESTS.unshift(json.quest);
|
||||
openQuestDialogueModal(json.quest.id, _questPickerOnUse);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage();
|
||||
});
|
||||
@@ -29,7 +29,10 @@ function collisionRuleTriggerSummary(rule) {
|
||||
}
|
||||
|
||||
function collisionRuleActionSummary(action) {
|
||||
if (action.type === 'quete') return COLLISION_ACTION_LABELS.quete + ' « ' + action.quete_id + ' »';
|
||||
if (action.type === 'quete') {
|
||||
var quest = (typeof questById === 'function') ? questById(action.quete_id) : null;
|
||||
return COLLISION_ACTION_LABELS.quete + ' « ' + (quest ? quest.title : '#' + action.quete_id) + ' »';
|
||||
}
|
||||
if (action.type === 'evenement') {
|
||||
var ev = CUSTOM_EVENTS_MAP[action.evenement_id];
|
||||
return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »';
|
||||
@@ -227,20 +230,19 @@ function collisionWizardSaveRule(action) {
|
||||
}
|
||||
|
||||
// Chaque type d'action demande éventuellement une précision avant de
|
||||
// pouvoir se finaliser (quête -> son id ; événement -> lequel) — "attaque"
|
||||
// n'a besoin de rien de plus, se finalise directement.
|
||||
// pouvoir se finaliser ("quête" ouvre l'éditeur de quête lui-même — voir
|
||||
// static/js/quests/quest-editor.js::renderQuestPickerStep, pas un simple
|
||||
// champ texte ; événement -> lequel) — "attaque" n'a besoin de rien de
|
||||
// plus, se finalise directement.
|
||||
function collisionWizardChooseAction(type, nested) {
|
||||
if (type === 'attaque' || type === 'interagir') {
|
||||
collisionWizardFinalizeAction({ type: type }, nested);
|
||||
return;
|
||||
}
|
||||
if (type === 'quete') {
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Identifiant de la quête (l\'éditeur de quête reste à construire — cette règle ne fait que mémoriser la référence pour l\'instant) :</p>' +
|
||||
'<div class="field is-grouped">' +
|
||||
'<div class="control is-expanded"><input type="text" id="collisionWizardQueteId" class="input" placeholder="ex. quete-001"></div>' +
|
||||
'<div class="control"><button type="button" class="button primary" onclick="collisionWizardSubmitQuete(' + (nested ? 'true' : 'false') + ')">Valider</button></div>' +
|
||||
'</div>';
|
||||
renderQuestPickerStep(function (quest) {
|
||||
collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, nested);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === 'evenement') {
|
||||
@@ -258,13 +260,6 @@ function collisionWizardChooseAction(type, nested) {
|
||||
}
|
||||
}
|
||||
|
||||
function collisionWizardSubmitQuete(nested) {
|
||||
var input = document.getElementById('collisionWizardQueteId');
|
||||
var queteId = input && input.value.trim();
|
||||
if (!queteId) { input.focus(); return; }
|
||||
collisionWizardFinalizeAction({ type: 'quete', quete_id: queteId }, nested);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
|
||||
});
|
||||
|
||||
+59
-1
@@ -690,7 +690,7 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.collisionRuleCard:hover{ transform:translateY(-3px); box-shadow:0 10px 26px rgba(0,0,0,.28); }
|
||||
.collisionRuleCardHeader{ display:flex; align-items:center; gap:10px; margin-bottom:12px; }
|
||||
.collisionRuleCardThumb{
|
||||
position:relative; width:40px; height:40px; border-radius:10px; overflow:hidden; background:var(--panel2);
|
||||
position:relative; width:120px; height:120px; border-radius:10px; overflow:hidden; background:var(--panel2);
|
||||
display:flex; align-items:center; justify-content:center; flex-shrink:0;
|
||||
border:1px solid var(--border); box-shadow:inset 0 0 0 2px rgba(255,255,255,.03);
|
||||
}
|
||||
@@ -760,6 +760,64 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
|
||||
.collisionInteractBubble{ display:none; position:absolute; left:50%; bottom:100%; transform:translateX(-50%); margin-bottom:6px; background:#111; color:#fff; border-radius:10px; padding:4px 10px; font-size:12px; white-space:nowrap; pointer-events:none; z-index:20; }
|
||||
|
||||
/* ---- Éditeur de quêtes (templates/quests.html + modale de dialogue
|
||||
partagée avec l'assistant "+ Action" de l'éditeur de collision, voir
|
||||
static/js/quests/quest-editor.js) ---- */
|
||||
.questsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:18px; margin-top:14px; }
|
||||
.questCard{
|
||||
background:linear-gradient(165deg, var(--panel), var(--panel2)); border:1px solid var(--border);
|
||||
border-radius:16px; padding:16px; cursor:pointer; box-shadow:0 6px 18px rgba(0,0,0,.18);
|
||||
animation:collisionCardIn .35s ease backwards; transition:transform .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.questCard:hover{ transform:translateY(-3px); box-shadow:0 10px 26px rgba(0,0,0,.28); border-color:var(--accent); }
|
||||
.questCardHeader{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
|
||||
.questCardTitle{ margin:0; font-size:15px; font-weight:700; }
|
||||
.questCardObjectif{ font-size:12.5px; color:var(--forge-text-muted, #8b95a7); margin-bottom:12px; min-height:1.4em; }
|
||||
.questCardFooter{ display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; }
|
||||
.questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; }
|
||||
.questPill-resultat{ background:rgba(23,162,184,.18); color:#17a2b8; }
|
||||
.questPill-status{ background:var(--panel2); border:1px solid var(--border); }
|
||||
.questPill-status-nouvelle{ background:rgba(155,89,182,.18); color:#9b59b6; border-color:transparent; }
|
||||
.questPill-status-en_cours{ background:rgba(242,153,74,.18); color:#f2994a; border-color:transparent; }
|
||||
.questPill-status-terminee{ background:rgba(46,204,113,.18); color:#2ecc71; border-color:transparent; }
|
||||
|
||||
.questDialogueModalContent{ max-width:min(92vw, 1080px); }
|
||||
.questDialogueModalHeader{ margin-bottom:10px; }
|
||||
.questFieldsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:10px; margin-bottom:14px; }
|
||||
.questField{ display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; }
|
||||
.questField-wide{ grid-column:span 2; }
|
||||
.questDialogueColumns{ display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; overflow-x:auto; }
|
||||
.questDialogueColumn{
|
||||
background:rgba(0,0,0,.15); border:1px solid var(--border); border-radius:12px; padding:10px;
|
||||
display:flex; flex-direction:column; gap:8px; min-height:160px;
|
||||
}
|
||||
.questDialogueColumnTitle{ margin:0 0 2px; font-size:13px; text-align:center; }
|
||||
.questDialogueColumnBubbles{ display:flex; flex-direction:column; gap:8px; flex:1; }
|
||||
.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; }
|
||||
|
||||
.questBubbleRow{ display:flex; }
|
||||
.questBubbleRow-pnj{ justify-content:flex-start; }
|
||||
.questBubbleRow-joueur{ justify-content:flex-end; }
|
||||
.questBubble{
|
||||
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; }
|
||||
.questBubble-joueur{ background:linear-gradient(135deg, #2ecc71, #1e9e57); border-bottom-right-radius:4px; }
|
||||
.questBubbleSpeaker{ font-size:10.5px; font-weight:700; color:rgba(255,255,255,.85); cursor:pointer; }
|
||||
.questBubbleText{
|
||||
resize:none; overflow:hidden; border:none; background:transparent; color:#fff; font-size:13px;
|
||||
font-family:inherit; padding:0; min-height:1.4em;
|
||||
}
|
||||
.questBubbleText::placeholder{ color:rgba(255,255,255,.6); }
|
||||
.questBubbleText:focus{ outline:none; }
|
||||
.questBubbleDelete{
|
||||
align-self:flex-end; background:none; border:none; color:rgba(255,255,255,.6); cursor:pointer;
|
||||
font-size:11px; padding:0; transition:color .15s ease;
|
||||
}
|
||||
.questBubbleDelete:hover{ color:#fff; }
|
||||
|
||||
.listRow.selected{ border-color:var(--accent); background:var(--panel2); }
|
||||
|
||||
.childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); }
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
une version simplifiée (juste les écrans) pour un type "restreint"
|
||||
(quiz/embranchement/rpg), la version complète pour "custom". -->
|
||||
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="{{ 'Écrans du jeu' if game.onboarding_type in ('quiz', 'embranchement', 'rpg') else 'Écrans, objets, templates, variables' }}">📊 Tableau de bord</a>
|
||||
<a href="{{ url_for('quests_view', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug ~ '/quests' else '' }}" title="Quêtes (objectif, récompense, statut, dialogue)">🗺️ Quêtes</a>
|
||||
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a>
|
||||
<!-- Phase 1.2 (feuille de route produit) : seul export retenu — paquet
|
||||
STATIQUE (aucun Python/Flask embarqué, voir
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Quêtes — {{ game.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="dashPanelHeader" style="align-items:center; justify-content:space-between;">
|
||||
<h2>🗺️ Quêtes</h2>
|
||||
<button type="button" class="collisionRuleAddBtn" onclick="createQuestFromListPage()">✚ Nouvelle quête</button>
|
||||
</div>
|
||||
<p class="hint">Une quête : un objectif, une récompense (score), un statut, un résultat — et un dialogue par statut, à écrire en cliquant sur sa carte.</p>
|
||||
|
||||
<div id="questsGrid" class="questsGrid"></div>
|
||||
|
||||
<script>
|
||||
var QUESTS_JSON = {{ quests_json|safe }};
|
||||
var GAME_SLUG = "{{ game.slug }}";
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/quests/quest-editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -443,6 +443,10 @@
|
||||
var COLLISION_ACTION_TYPES = {{ collision_action_types|tojson }};
|
||||
var COLLISION_TRIGGER_TYPES = {{ collision_trigger_types|tojson }};
|
||||
var COLLISION_DEFAULT_PERIMETRE_PX = {{ collision_default_perimetre_px }};
|
||||
// Éditeur de quêtes (voir static/js/quests/quest-editor.js) — chargé
|
||||
// avant collision-rules-editor.js : l'action "quete" de l'assistant
|
||||
// "+ Action" ouvre directement cet éditeur (renderQuestPickerStep).
|
||||
var QUESTS_JSON = {{ quests_json|safe }};
|
||||
</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>
|
||||
@@ -450,5 +454,6 @@
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/builder-tabs.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/quests/quest-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/scenes/collision-rules-editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,6 +7,7 @@ construit l'assistant "+ Action" ; ce fichier teste la couche serveur
|
||||
(défauts, validation, exposition dans full_game_payload)."""
|
||||
import re
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
|
||||
@@ -68,16 +69,17 @@ def test_rules_route_persists_a_perimeter_trigger_with_custom_radius(client, tmp
|
||||
assert rules == [{"trigger": "perimetre", "perimetre_px": 40.0, "action": {"type": "attaque"}}]
|
||||
|
||||
|
||||
def test_rules_route_persists_a_quest_action_with_free_form_id(client, tmp_game_slug_cleanup):
|
||||
def test_rules_route_persists_a_quest_action_referencing_a_quest_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
pnj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules",
|
||||
data='{"rules": [{"trigger": "collision", "action": {"type": "quete", "quete_id": "quete-001"}}]}',
|
||||
data='{"rules": [{"trigger": "collision", "action": {"type": "quete", "quete_id": ' + str(quest["id"]) + '}}]}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "quete", "quete_id": "quete-001"}}]
|
||||
assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "quete", "quete_id": quest["id"]}}]
|
||||
|
||||
|
||||
def test_rules_route_persists_an_interagir_action_with_one_level_of_nesting(client, tmp_game_slug_cleanup):
|
||||
@@ -89,11 +91,11 @@ def test_rules_route_persists_an_interagir_action_with_one_level_of_nesting(clie
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules",
|
||||
data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": "q1"}}}]}',
|
||||
data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": 1}}}]}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.get_json()["rules"] == [
|
||||
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": "q1"}}},
|
||||
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": 1}}},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Éditeur de quêtes (voir db/quests/, routes/quests/, nouveau lien
|
||||
"🗺️ Quêtes" dans templates/base.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 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():
|
||||
raw = {
|
||||
"nouvelle": [{"speaker": "pnj", "text": "Bonjour !"}, {"speaker": "joueur", "text": " "}],
|
||||
"en_cours": [{"speaker": "sorcier", "text": "invalide"}, "pas un objet"],
|
||||
"terminee": "pas une liste",
|
||||
"statut_inconnu": [{"speaker": "pnj", "text": "ignoré"}],
|
||||
}
|
||||
cleaned = db.sanitize_quest_dialogues(raw)
|
||||
assert cleaned == {
|
||||
"nouvelle": [{"speaker": "pnj", "text": "Bonjour !"}],
|
||||
"en_cours": [],
|
||||
"terminee": [],
|
||||
}
|
||||
|
||||
|
||||
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": [{"speaker": "pnj", "text": "Peux-tu m'aider ?"}, {"speaker": "joueur", "text": "Bien sûr !"}],
|
||||
"en_cours": [{"speaker": "pnj", "text": "Alors, cette clé ?"}],
|
||||
"terminee": [{"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_quests_view_route_renders_the_list(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
db.create_quest(slug, "Trouver la clé")
|
||||
resp = client.get(f"/game/{slug}/quests")
|
||||
assert resp.status_code == 200
|
||||
assert b"Trouver la cl" in resp.data
|
||||
|
||||
|
||||
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"] == [{"speaker": "pnj", "text": "Salut !"}]
|
||||
assert db.get_quest(slug, quest["id"])["dialogues"]["nouvelle"] == [{"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
|
||||
Reference in New Issue
Block a user