Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+289 -118
View File
@@ -18,16 +18,150 @@ utilisée par app.py et les templates, afin que `import screens` continue
de fonctionner à l'identique.
"""
from .animations.add_animation_clip import add_animation_clip
from .animations.animate_css_catalog import ANIMATE_CSS_CATALOG, ANIMATE_CSS_LABELS
from .animations.delete_animation_clip import delete_animation_clip
from .animations.ensure_animation_schema import ensure_animation_schema
from .animations.get_animation_clip import get_animation_clip
from .animations.list_animation_clips import list_animation_clips
from .animations.update_animation_clip import update_animation_clip
from .custom_events.delete_custom_event import delete_custom_event
from .custom_events.list_custom_event_usages import list_custom_event_usages
from .data_actions.apply_add_row_action import apply_add_row_action
from .data_actions.apply_data_action import apply_data_action
from .data_actions.apply_score_action import apply_score_action
from .data_actions.apply_status_action import apply_status_action
from .data_actions.apply_variable_action import apply_variable_action
from .data_definitions.data_definition_options import data_definition_options
from .labels.action_type_labels import ACTION_TYPE_LABELS
from .labels.element_action_properties import (
ELEMENT_ORIENTATION_VALUES, ELEMENT_ORIENTATION_LABELS,
from .flow.add_flow_edge import add_flow_edge
from .flow.add_flow_node import add_flow_node
from .flow.blocks.blocks_view import blocks_view
from .flow.blocks.create_flow_block import create_flow_block
from .flow.blocks.delete_flow_block import delete_flow_block
from .flow.blocks.get_flow_block import get_flow_block
from .flow.blocks.list_flow_blocks import list_flow_blocks
from .flow.blocks.update_flow_block import update_flow_block
from .flow.constants import (
CLICKED_ROW_ID,
CONDITION_OPERATOR_LABELS,
CONDITION_OPERATORS,
LAST_INSERTED_ROW_ID,
TRIGGER_EVENTS,
)
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
from .labels.sprite_library import PUBLIC_SPRITE_LIBRARY
from .flow.delete_flow_edge import delete_flow_edge
from .flow.delete_flow_node import delete_flow_node
from .flow.ensure_flow_schema import ensure_flow_schema
from .flow.get_flow_node import get_flow_node
from .flow.list_flow_edges import list_flow_edges
from .flow.list_flow_nodes import list_flow_nodes
from .flow.move_flow_node import move_flow_node
from .ia.add_ia_chat_message import add_ia_chat_message
from .ia.create_ia_conversation import create_ia_conversation
from .ia.delete_ia_conversation import delete_ia_conversation
from .ia.ensure_ia_chat_schema import ensure_ia_chat_schema
from .ia.get_ia_conversation import get_ia_conversation
from .ia.list_ia_chat_messages import list_ia_chat_messages
from .ia.list_ia_conversations import list_ia_conversations
from .labels.action_type_labels import ACTION_TYPE_LABELS
from .labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
from .labels.background_library import BACKGROUND_LIBRARY
from .labels.data_operations import DATA_OPERATION_LABELS, DATA_OPERATIONS
from .labels.element_action_properties import (
ELEMENT_ORIENTATION_LABELS,
ELEMENT_ORIENTATION_VALUES,
)
from .labels.element_visibility import ELEMENT_VISIBILITY_LABELS, ELEMENT_VISIBILITY_VALUES
from .labels.sprite_gallery import sprite_gallery_families
from .labels.sprite_library import PUBLIC_SPRITE_LIBRARY
from .labels.surbrillance_values import SURBRILLANCE_LABELS, SURBRILLANCE_VALUES
from .labels.video_modes import VIDEO_MODE_LABELS, VIDEO_MODES
from .payload.full_game_payload import full_game_payload
from .rendering.collision_rules import (
backfill_missing_leaf_ids,
move_action_in_rules,
remove_action_from_rules,
resolve_collision_rules,
sanitize_collision_rules,
set_dialogue_lines_in_rules,
set_then_action_in_rules,
)
from .rendering.collision_settings import COLLISION_SHAPES, DEFAULT_COLLISION_SETTINGS, resolve_collision_settings
from .rendering.dialogue_box_style import (
DEFAULT_DIALOGUE_BOX_STYLE,
FONT_WEIGHT_CHOICES,
resolve_dialogue_box_style,
sanitize_dialogue_box_style,
)
from .rendering.list_used_forge_characters import list_used_forge_characters
from .rendering.personnage_commands import (
DEFAULT_PERSONNAGE_COMMANDS,
resolve_personnage_commands,
sanitize_extra_commands,
)
from .rendering.personnage_data import (
resolve_personnage_animations,
resolve_personnage_data,
resolve_personnage_idle_frame,
)
from .rendering.personnage_role import (
DEFAULT_PERSONNAGE_ROLE,
PERSONNAGE_ROLE_LABELS,
PERSONNAGE_ROLES,
resolve_personnage_role,
)
from .rendering.quiz_box_config import (
DEFAULT_QUIZ_BOX_CONFIG,
QUIZ_BOX_DIALOG_TEMPLATE_LABELS,
QUIZ_BOX_DIALOG_TEMPLATES,
QUIZ_BOX_PAGE_TEMPLATE_LABELS,
QUIZ_BOX_PAGE_TEMPLATES,
TIMER_MODES,
resolve_quiz_box_config,
sanitize_quiz_box_config,
)
from .rendering.scene_object_names import list_named_scene_object_names
from .rendering.screen_triggers import TRIGGER_TYPES_SCREEN, resolve_screen_triggers, sanitize_screen_triggers
from .rendering.trigger_graph import (
collect_all_dialogue_lines,
list_all_collision_objects,
list_all_triggers,
list_completable_dialogue_ids,
)
from .scenes.add_scene_object import add_scene_object
from .scenes.append_action_to_trigger import append_action_to_trigger
from .scenes.build_collision_rule_objects import build_collision_rule_objects
from .scenes.delete_scene_object import delete_scene_object
from .scenes.ensure_scene_schema import ensure_scene_schema
from .scenes.flow_palette import ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES, TRIGGER_EVENTS_2D
from .scenes.get_scene_object import get_scene_object
from .scenes.list_scene_objects import list_scene_objects
from .scenes.move_action_in_trigger import move_action_in_trigger
from .scenes.remove_action_from_trigger import remove_action_from_trigger
from .scenes.render_scene_object import render_scene_object
from .scenes.resolve_scene_world_size import resolve_scene_world_size
from .scenes.set_scene_object_collision import set_scene_object_collision
from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules
from .scenes.set_scene_object_commands import set_scene_object_commands
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
from .scenes.set_scene_object_name import set_scene_object_name
from .scenes.set_scene_object_personnage_data import set_scene_object_personnage_data
from .scenes.set_scene_object_quiz_config import set_scene_object_quiz_config
from .scenes.set_scene_object_role import set_scene_object_role
from .scenes.update_scene_object_geometry import update_scene_object_geometry
from .screens_repo.append_action_to_screen_trigger import append_action_to_screen_trigger
from .screens_repo.create_screen import create_screen
from .screens_repo.delete_screen import delete_screen
from .screens_repo.ensure_schema import ensure_schema
from .screens_repo.get_screen import get_screen
from .screens_repo.list_screens import list_screens
from .screens_repo.move_action_in_screen_trigger import move_action_in_screen_trigger
from .screens_repo.move_screen import move_screen
from .screens_repo.remove_action_from_screen_trigger import remove_action_from_screen_trigger
from .screens_repo.rename_screen import rename_screen
from .screens_repo.screens_referencing import screens_referencing
from .screens_repo.set_scene_size import set_scene_size
from .screens_repo.set_screen_background_music import set_screen_background_music
from .screens_repo.set_screen_triggers import set_screen_triggers
# SPRITE_LIBRARY (fusionné) : utilisé par resolve_personnage_animations
# pour la résolution RUNTIME (jeu jouable, tous rôles confondus — voir le
@@ -37,119 +171,156 @@ from .labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
# ou ADMIN_SPRITE_LIBRARY selon le rôle, jamais SPRITE_LIBRARY directement.
SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY}
ADMIN_ONLY_CHARACTER_SLUGS = set(ADMIN_SPRITE_LIBRARY)
from .labels.sprite_gallery import sprite_gallery_families
from .labels.background_library import BACKGROUND_LIBRARY
# Un seul pack aujourd'hui, licence CraftPix — tout BACKGROUND_LIBRARY est
# admin-only (voir core/sprite_gate.py::forbid_admin_only_background),
# jamais de version "publique" comme pour les personnages Kenney.
ADMIN_ONLY_BACKGROUND_SLUGS = set(BACKGROUND_LIBRARY)
from .rendering.list_used_forge_characters import list_used_forge_characters
from .flow.constants import (
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID,
)
from .rendering.trigger_for import _trigger_for
from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame
from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE
from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES
from .rendering.collision_rules import (
resolve_collision_rules, sanitize_collision_rules, set_dialogue_lines_in_rules, set_then_action_in_rules,
remove_action_from_rules, move_action_in_rules, backfill_missing_leaf_ids,
)
from .rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers, TRIGGER_TYPES_SCREEN
from .labels.surbrillance_values import SURBRILLANCE_VALUES, SURBRILLANCE_LABELS
from .labels.video_modes import VIDEO_MODES, VIDEO_MODE_LABELS
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
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.quiz_box_config import (
resolve_quiz_box_config, sanitize_quiz_box_config, DEFAULT_QUIZ_BOX_CONFIG,
TIMER_MODES, QUIZ_BOX_DIALOG_TEMPLATES, QUIZ_BOX_DIALOG_TEMPLATE_LABELS,
QUIZ_BOX_PAGE_TEMPLATES, QUIZ_BOX_PAGE_TEMPLATE_LABELS,
)
from .screens_repo.ensure_schema import ensure_schema
from .screens_repo.list_screens import list_screens
from .screens_repo.get_screen import get_screen
from .screens_repo.create_screen import create_screen
from .screens_repo.rename_screen import rename_screen
from .screens_repo.delete_screen import delete_screen
from .screens_repo.move_screen import move_screen
from .screens_repo.screens_referencing import screens_referencing
from .screens_repo.set_scene_size import set_scene_size
from .screens_repo.set_screen_background_music import set_screen_background_music
from .screens_repo.set_screen_triggers import set_screen_triggers
from .screens_repo.append_action_to_screen_trigger import append_action_to_screen_trigger
from .screens_repo.remove_action_from_screen_trigger import remove_action_from_screen_trigger
from .screens_repo.move_action_in_screen_trigger import move_action_in_screen_trigger
from .scenes.ensure_scene_schema import ensure_scene_schema
from .scenes.add_scene_object import add_scene_object
from .scenes.list_scene_objects import list_scene_objects
from .scenes.resolve_scene_world_size import resolve_scene_world_size
from .scenes.get_scene_object import get_scene_object
from .scenes.update_scene_object_geometry import update_scene_object_geometry
from .scenes.delete_scene_object import delete_scene_object
from .scenes.set_scene_object_personnage_data import set_scene_object_personnage_data
from .scenes.set_scene_object_commands import set_scene_object_commands
from .scenes.set_scene_object_role import set_scene_object_role
from .scenes.set_scene_object_collision import set_scene_object_collision
from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules
from .scenes.set_scene_object_name import set_scene_object_name
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
from .scenes.set_scene_object_quiz_config import set_scene_object_quiz_config
from .scenes.render_scene_object import render_scene_object
from .scenes.build_collision_rule_objects import build_collision_rule_objects
from .scenes.append_action_to_trigger import append_action_to_trigger
from .scenes.remove_action_from_trigger import remove_action_from_trigger
from .scenes.move_action_in_trigger import move_action_in_trigger
from .rendering.trigger_graph import (
list_all_triggers, list_all_collision_objects, collect_all_dialogue_lines,
list_completable_dialogue_ids,
)
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
from .flow.ensure_flow_schema import ensure_flow_schema
from .flow.list_flow_nodes import list_flow_nodes
from .flow.list_flow_edges import list_flow_edges
from .flow.get_flow_node import get_flow_node
from .flow.add_flow_node import add_flow_node
from .flow.move_flow_node import move_flow_node
from .flow.delete_flow_node import delete_flow_node
from .flow.add_flow_edge import add_flow_edge
from .flow.delete_flow_edge import delete_flow_edge
from .flow.blocks.list_flow_blocks import list_flow_blocks
from .flow.blocks.get_flow_block import get_flow_block
from .flow.blocks.create_flow_block import create_flow_block
from .flow.blocks.update_flow_block import update_flow_block
from .flow.blocks.delete_flow_block import delete_flow_block
from .flow.blocks.blocks_view import blocks_view
from .data_actions.apply_data_action import apply_data_action
from .data_actions.apply_variable_action import apply_variable_action
from .data_actions.apply_score_action import apply_score_action
from .data_actions.apply_status_action import apply_status_action
from .data_actions.apply_add_row_action import apply_add_row_action
from .animations.ensure_animation_schema import ensure_animation_schema
from .animations.list_animation_clips import list_animation_clips
from .animations.get_animation_clip import get_animation_clip
from .animations.add_animation_clip import add_animation_clip
from .animations.update_animation_clip import update_animation_clip
from .animations.delete_animation_clip import delete_animation_clip
from .animations.animate_css_catalog import ANIMATE_CSS_CATALOG, ANIMATE_CSS_LABELS
from .custom_events.list_custom_event_usages import list_custom_event_usages
from .custom_events.delete_custom_event import delete_custom_event
from .payload.full_game_payload import full_game_payload
from .ia.ensure_ia_chat_schema import ensure_ia_chat_schema
from .ia.list_ia_chat_messages import list_ia_chat_messages
from .ia.add_ia_chat_message import add_ia_chat_message
from .ia.create_ia_conversation import create_ia_conversation
from .ia.list_ia_conversations import list_ia_conversations
from .ia.get_ia_conversation import get_ia_conversation
from .ia.delete_ia_conversation import delete_ia_conversation
# Liste generee a partir des noms REELLEMENT importables depuis ce module
# (introspection de vars(screens) + parsing AST des imports de ce fichier,
# les deux methodes se recoupent exactement — voir discussion Phase 3) —
# jamais retapee a la main. `_trigger_for` (rendering/trigger_for.py) est
# volontairement exclu : prefixe underscore = prive par convention, et
# confirme inutilise en dehors de ce fichier (grep sur tout le depot).
__all__ = [
"data_definition_options",
"ACTION_TYPE_LABELS",
"ELEMENT_ORIENTATION_VALUES",
"ELEMENT_ORIENTATION_LABELS",
"ELEMENT_VISIBILITY_VALUES",
"ELEMENT_VISIBILITY_LABELS",
"DATA_OPERATIONS",
"DATA_OPERATION_LABELS",
"PUBLIC_SPRITE_LIBRARY",
"ADMIN_SPRITE_LIBRARY",
"SPRITE_LIBRARY",
"ADMIN_ONLY_CHARACTER_SLUGS",
"sprite_gallery_families",
"BACKGROUND_LIBRARY",
"ADMIN_ONLY_BACKGROUND_SLUGS",
"list_used_forge_characters",
"TRIGGER_EVENTS",
"CONDITION_OPERATORS",
"CONDITION_OPERATOR_LABELS",
"CLICKED_ROW_ID",
"LAST_INSERTED_ROW_ID",
"resolve_personnage_data",
"resolve_personnage_animations",
"resolve_personnage_idle_frame",
"resolve_personnage_role",
"PERSONNAGE_ROLES",
"PERSONNAGE_ROLE_LABELS",
"DEFAULT_PERSONNAGE_ROLE",
"resolve_collision_settings",
"DEFAULT_COLLISION_SETTINGS",
"COLLISION_SHAPES",
"resolve_collision_rules",
"sanitize_collision_rules",
"set_dialogue_lines_in_rules",
"set_then_action_in_rules",
"remove_action_from_rules",
"move_action_in_rules",
"backfill_missing_leaf_ids",
"resolve_screen_triggers",
"sanitize_screen_triggers",
"TRIGGER_TYPES_SCREEN",
"SURBRILLANCE_VALUES",
"SURBRILLANCE_LABELS",
"VIDEO_MODES",
"VIDEO_MODE_LABELS",
"resolve_personnage_commands",
"DEFAULT_PERSONNAGE_COMMANDS",
"sanitize_extra_commands",
"list_named_scene_object_names",
"resolve_dialogue_box_style",
"sanitize_dialogue_box_style",
"DEFAULT_DIALOGUE_BOX_STYLE",
"FONT_WEIGHT_CHOICES",
"resolve_quiz_box_config",
"sanitize_quiz_box_config",
"DEFAULT_QUIZ_BOX_CONFIG",
"TIMER_MODES",
"QUIZ_BOX_DIALOG_TEMPLATES",
"QUIZ_BOX_DIALOG_TEMPLATE_LABELS",
"QUIZ_BOX_PAGE_TEMPLATES",
"QUIZ_BOX_PAGE_TEMPLATE_LABELS",
"ensure_schema",
"list_screens",
"get_screen",
"create_screen",
"rename_screen",
"delete_screen",
"move_screen",
"screens_referencing",
"set_scene_size",
"set_screen_background_music",
"set_screen_triggers",
"append_action_to_screen_trigger",
"remove_action_from_screen_trigger",
"move_action_in_screen_trigger",
"ensure_scene_schema",
"add_scene_object",
"list_scene_objects",
"resolve_scene_world_size",
"get_scene_object",
"update_scene_object_geometry",
"delete_scene_object",
"set_scene_object_personnage_data",
"set_scene_object_commands",
"set_scene_object_role",
"set_scene_object_collision",
"set_scene_object_collision_rules",
"set_scene_object_name",
"set_scene_object_dialogue_box_style",
"set_scene_object_quiz_config",
"render_scene_object",
"build_collision_rule_objects",
"append_action_to_trigger",
"remove_action_from_trigger",
"move_action_in_trigger",
"list_all_triggers",
"list_all_collision_objects",
"collect_all_dialogue_lines",
"list_completable_dialogue_ids",
"TRIGGER_EVENTS_2D",
"ACTION_TYPE_LABELS_2D",
"OBJECT_ACTION_PROPERTIES",
"ensure_flow_schema",
"list_flow_nodes",
"list_flow_edges",
"get_flow_node",
"add_flow_node",
"move_flow_node",
"delete_flow_node",
"add_flow_edge",
"delete_flow_edge",
"list_flow_blocks",
"get_flow_block",
"create_flow_block",
"update_flow_block",
"delete_flow_block",
"blocks_view",
"apply_data_action",
"apply_variable_action",
"apply_score_action",
"apply_status_action",
"apply_add_row_action",
"ensure_animation_schema",
"list_animation_clips",
"get_animation_clip",
"add_animation_clip",
"update_animation_clip",
"delete_animation_clip",
"ANIMATE_CSS_CATALOG",
"ANIMATE_CSS_LABELS",
"list_custom_event_usages",
"delete_custom_event",
"full_game_payload",
"ensure_ia_chat_schema",
"list_ia_chat_messages",
"add_ia_chat_message",
"create_ia_conversation",
"list_ia_conversations",
"get_ia_conversation",
"delete_ia_conversation",
]
+28 -7
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
import db
@@ -7,15 +8,25 @@ from .ensure_animation_schema import ensure_animation_schema
# Point de départ raisonnable pour un nouveau clip "personnalisé" : un
# fondu simple de 0 à 1, que le créateur du jeu peut ensuite modifier ou
# remplacer entièrement dans l'éditeur de images-clés.
_DEFAULT_CUSTOM_KEYFRAMES = [
_DEFAULT_CUSTOM_KEYFRAMES: list[dict[str, Any]] = [
{"percent": 0, "props": {"opacity": "0"}},
{"percent": 100, "props": {"opacity": "1"}},
]
def add_animation_clip(slug, screen_id, element_id, kind="animate_css", animate_name=None,
custom_keyframes=None, start_time=0, duration=1, delay=0,
easing="ease", iteration_count="1"):
def add_animation_clip(
slug: str,
screen_id: int,
element_id: int,
kind: str = "animate_css",
animate_name: str | None = None,
custom_keyframes: list[dict[str, Any]] | None = None,
start_time: float = 0,
duration: float = 1,
delay: float = 0,
easing: str = "ease",
iteration_count: str = "1",
) -> int:
ensure_animation_schema(slug)
if kind == "custom" and not custom_keyframes:
custom_keyframes = _DEFAULT_CUSTOM_KEYFRAMES
@@ -28,10 +39,20 @@ def add_animation_clip(slug, screen_id, element_id, kind="animate_css", animate_
(screen_id, element_id, kind, animate_name, custom_keyframes,
start_time, duration, delay, easing, iteration_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(screen_id, element_id, kind, animate_name, keyframes_json,
float(start_time), float(duration), float(delay), easing, str(iteration_count)),
(
screen_id,
element_id,
kind,
animate_name,
keyframes_json,
float(start_time),
float(duration),
float(delay),
easing,
str(iteration_count),
),
)
clip_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
clip_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return clip_id
+137 -64
View File
@@ -6,70 +6,143 @@
# templates/play.html). Regroupées par catégorie pour un menu déroulant
# plus lisible que 80 entrées à plat.
ANIMATE_CSS_CATALOG = [
("Attirer l'attention", [
("bounce", "Rebond"), ("flash", "Flash"), ("pulse", "Pulsation"),
("rubberBand", "Élastique"), ("shakeX", "Secousse horizontale"),
("shakeY", "Secousse verticale"), ("headShake", "Hoche la tête"),
("swing", "Balancement"), ("tada", "Tada"), ("wobble", "Vacillement"),
("jello", "Gélatine"), ("heartBeat", "Battement de cœur"),
]),
("Entrée — fondu", [
("fadeIn", "Apparition"), ("fadeInDown", "Apparition depuis le haut"),
("fadeInLeft", "Apparition depuis la gauche"), ("fadeInRight", "Apparition depuis la droite"),
("fadeInUp", "Apparition depuis le bas"),
]),
("Sortie — fondu", [
("fadeOut", "Disparition"), ("fadeOutDown", "Disparition vers le bas"),
("fadeOutLeft", "Disparition vers la gauche"), ("fadeOutRight", "Disparition vers la droite"),
("fadeOutUp", "Disparition vers le haut"),
]),
("Entrée — rebond", [
("bounceIn", "Rebond entrant"), ("bounceInDown", "Rebond depuis le haut"),
("bounceInLeft", "Rebond depuis la gauche"), ("bounceInRight", "Rebond depuis la droite"),
("bounceInUp", "Rebond depuis le bas"),
]),
("Sortie — rebond", [
("bounceOut", "Rebond sortant"), ("bounceOutDown", "Rebond vers le bas"),
("bounceOutLeft", "Rebond vers la gauche"), ("bounceOutRight", "Rebond vers la droite"),
("bounceOutUp", "Rebond vers le haut"),
]),
("Entrée — glissement", [
("slideInDown", "Glisse depuis le haut"), ("slideInLeft", "Glisse depuis la gauche"),
("slideInRight", "Glisse depuis la droite"), ("slideInUp", "Glisse depuis le bas"),
]),
("Sortie — glissement", [
("slideOutDown", "Glisse vers le bas"), ("slideOutLeft", "Glisse vers la gauche"),
("slideOutRight", "Glisse vers la droite"), ("slideOutUp", "Glisse vers le haut"),
]),
("Entrée — zoom", [
("zoomIn", "Zoom entrant"), ("zoomInDown", "Zoom depuis le haut"),
("zoomInLeft", "Zoom depuis la gauche"), ("zoomInRight", "Zoom depuis la droite"),
("zoomInUp", "Zoom depuis le bas"),
]),
("Sortie — zoom", [
("zoomOut", "Zoom sortant"), ("zoomOutDown", "Zoom vers le bas"),
("zoomOutLeft", "Zoom vers la gauche"), ("zoomOutRight", "Zoom vers la droite"),
("zoomOutUp", "Zoom vers le haut"),
]),
("Rotation", [
("rotateIn", "Rotation entrante"), ("rotateOut", "Rotation sortante"),
("rotateInDownLeft", "Rotation depuis haut-gauche"), ("rotateInDownRight", "Rotation depuis haut-droite"),
("rotateInUpLeft", "Rotation depuis bas-gauche"), ("rotateInUpRight", "Rotation depuis bas-droite"),
]),
("Retournement", [
("flip", "Retournement"), ("flipInX", "Retournement horizontal entrant"),
("flipInY", "Retournement vertical entrant"), ("flipOutX", "Retournement horizontal sortant"),
("flipOutY", "Retournement vertical sortant"),
]),
("Vitesse lumière", [
("lightSpeedInRight", "Entrée vitesse lumière — droite"), ("lightSpeedInLeft", "Entrée vitesse lumière — gauche"),
("lightSpeedOutRight", "Sortie vitesse lumière — droite"), ("lightSpeedOutLeft", "Sortie vitesse lumière — gauche"),
]),
("Spéciales", [
("jackInTheBox", "Boîte à surprise"), ("rollIn", "Roule à l'entrée"), ("rollOut", "Roule à la sortie"),
("backInDown", "Recul entrant — haut"), ("backInUp", "Recul entrant — bas"),
("backOutDown", "Recul sortant — bas"), ("backOutUp", "Recul sortant — haut"),
]),
(
"Attirer l'attention",
[
("bounce", "Rebond"),
("flash", "Flash"),
("pulse", "Pulsation"),
("rubberBand", "Élastique"),
("shakeX", "Secousse horizontale"),
("shakeY", "Secousse verticale"),
("headShake", "Hoche la tête"),
("swing", "Balancement"),
("tada", "Tada"),
("wobble", "Vacillement"),
("jello", "Gélatine"),
("heartBeat", "Battement de cœur"),
],
),
(
"Entrée — fondu",
[
("fadeIn", "Apparition"),
("fadeInDown", "Apparition depuis le haut"),
("fadeInLeft", "Apparition depuis la gauche"),
("fadeInRight", "Apparition depuis la droite"),
("fadeInUp", "Apparition depuis le bas"),
],
),
(
"Sortie — fondu",
[
("fadeOut", "Disparition"),
("fadeOutDown", "Disparition vers le bas"),
("fadeOutLeft", "Disparition vers la gauche"),
("fadeOutRight", "Disparition vers la droite"),
("fadeOutUp", "Disparition vers le haut"),
],
),
(
"Entrée — rebond",
[
("bounceIn", "Rebond entrant"),
("bounceInDown", "Rebond depuis le haut"),
("bounceInLeft", "Rebond depuis la gauche"),
("bounceInRight", "Rebond depuis la droite"),
("bounceInUp", "Rebond depuis le bas"),
],
),
(
"Sortie — rebond",
[
("bounceOut", "Rebond sortant"),
("bounceOutDown", "Rebond vers le bas"),
("bounceOutLeft", "Rebond vers la gauche"),
("bounceOutRight", "Rebond vers la droite"),
("bounceOutUp", "Rebond vers le haut"),
],
),
(
"Entrée — glissement",
[
("slideInDown", "Glisse depuis le haut"),
("slideInLeft", "Glisse depuis la gauche"),
("slideInRight", "Glisse depuis la droite"),
("slideInUp", "Glisse depuis le bas"),
],
),
(
"Sortie — glissement",
[
("slideOutDown", "Glisse vers le bas"),
("slideOutLeft", "Glisse vers la gauche"),
("slideOutRight", "Glisse vers la droite"),
("slideOutUp", "Glisse vers le haut"),
],
),
(
"Entrée — zoom",
[
("zoomIn", "Zoom entrant"),
("zoomInDown", "Zoom depuis le haut"),
("zoomInLeft", "Zoom depuis la gauche"),
("zoomInRight", "Zoom depuis la droite"),
("zoomInUp", "Zoom depuis le bas"),
],
),
(
"Sortie — zoom",
[
("zoomOut", "Zoom sortant"),
("zoomOutDown", "Zoom vers le bas"),
("zoomOutLeft", "Zoom vers la gauche"),
("zoomOutRight", "Zoom vers la droite"),
("zoomOutUp", "Zoom vers le haut"),
],
),
(
"Rotation",
[
("rotateIn", "Rotation entrante"),
("rotateOut", "Rotation sortante"),
("rotateInDownLeft", "Rotation depuis haut-gauche"),
("rotateInDownRight", "Rotation depuis haut-droite"),
("rotateInUpLeft", "Rotation depuis bas-gauche"),
("rotateInUpRight", "Rotation depuis bas-droite"),
],
),
(
"Retournement",
[
("flip", "Retournement"),
("flipInX", "Retournement horizontal entrant"),
("flipInY", "Retournement vertical entrant"),
("flipOutX", "Retournement horizontal sortant"),
("flipOutY", "Retournement vertical sortant"),
],
),
(
"Vitesse lumière",
[
("lightSpeedInRight", "Entrée vitesse lumière — droite"),
("lightSpeedInLeft", "Entrée vitesse lumière — gauche"),
("lightSpeedOutRight", "Sortie vitesse lumière — droite"),
("lightSpeedOutLeft", "Sortie vitesse lumière — gauche"),
],
),
(
"Spéciales",
[
("jackInTheBox", "Boîte à surprise"),
("rollIn", "Roule à l'entrée"),
("rollOut", "Roule à la sortie"),
("backInDown", "Recul entrant — haut"),
("backInUp", "Recul entrant — bas"),
("backOutDown", "Recul sortant — bas"),
("backOutUp", "Recul sortant — haut"),
],
),
]
# Map key -> label, pour l'affichage côté éditeur (nom du nœud, etc.).
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def delete_animation_clip(slug, clip_id):
def delete_animation_clip(slug: str, clip_id: int) -> None:
conn = db.connect(slug)
conn.execute("DELETE FROM _animation_clips WHERE id = ?", (clip_id,))
conn.commit()
@@ -1,3 +1,5 @@
import sqlite3
import db
_CREATE_TABLE = """
@@ -18,7 +20,7 @@ _CREATE_TABLE = """
"""
def ensure_animation_schema(slug):
def ensure_animation_schema(slug: str) -> None:
"""Crée la table des clips d'animation si elle n'existe pas encore —
même principe que ensure_flow_schema.py : un jeu créé avant l'ajout de
cette fonctionnalité n'a pas besoin d'être migré à la main, la première
@@ -40,9 +42,7 @@ def ensure_animation_schema(slug):
if not cols:
conn.executescript(_CREATE_TABLE)
elif cols["element_id"]["notnull"] and _has_fk_on_element_id(conn):
conn.executescript(
_CREATE_TABLE.replace("CREATE TABLE _animation_clips", "CREATE TABLE _animation_clips_new")
)
conn.executescript(_CREATE_TABLE.replace("CREATE TABLE _animation_clips", "CREATE TABLE _animation_clips_new"))
conn.execute(
"""INSERT INTO _animation_clips_new
(id, screen_id, element_id, kind, animate_name, custom_keyframes,
@@ -57,7 +57,7 @@ def ensure_animation_schema(slug):
conn.close()
def _has_fk_on_element_id(conn):
def _has_fk_on_element_id(conn: sqlite3.Connection) -> bool:
return any(
fk["from"] == "element_id" for fk in conn.execute("PRAGMA foreign_key_list(_animation_clips)").fetchall()
)
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .ensure_animation_schema import ensure_animation_schema
def get_animation_clip(slug, clip_id):
def get_animation_clip(slug: str, clip_id: int) -> dict[str, Any] | None:
ensure_animation_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _animation_clips WHERE id = ?", (clip_id,)).fetchone()
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .ensure_animation_schema import ensure_animation_schema
def list_animation_clips(slug, screen_id):
def list_animation_clips(slug: str, screen_id: int) -> list[dict[str, Any]]:
ensure_animation_schema(slug)
conn = db.connect(slug)
rows = conn.execute(
+12 -4
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
import db
@@ -8,13 +9,20 @@ from .ensure_animation_schema import ensure_animation_schema
# souris dans le panneau timeline n'envoie que start_time/duration, le
# formulaire d'édition du clip peut envoyer n'importe lequel des autres.
_UPDATABLE_FIELDS = {
"kind", "animate_name", "start_time", "duration", "delay", "easing", "iteration_count",
"kind",
"animate_name",
"start_time",
"duration",
"delay",
"easing",
"iteration_count",
}
def update_animation_clip(slug, clip_id, **fields):
def update_animation_clip(slug: str, clip_id: int, **fields: Any) -> None:
ensure_animation_schema(slug)
sets, vals = [], []
sets: list[str] = []
vals: list[Any] = []
for key, value in fields.items():
if key == "custom_keyframes":
sets.append("custom_keyframes = ?")
@@ -28,6 +36,6 @@ def update_animation_clip(slug, clip_id, **fields):
return
vals.append(clip_id)
conn = db.connect(slug)
conn.execute(f"UPDATE _animation_clips SET {', '.join(sets)} WHERE id = ?", vals)
conn.execute(f"UPDATE _animation_clips SET {', '.join(sets)} WHERE id = ?", vals) # nosec B608 # noqa: S608 - sets construit uniquement a partir de _UPDATABLE_FIELDS (whitelist codee en dur ligne 22), jamais d'une cle arbitraire ; valeurs parametrees (?)
conn.commit()
conn.close()
-45
View File
@@ -1,45 +0,0 @@
import json
# Partagé par control_value.py (widgets) et filter_repeater_rows.py
# (rendering) — module "feuille" sans import de l'un ou l'autre, pour ne
# jamais introduire de dépendance circulaire entre ces deux packages.
def decode_clauses(attrs, clauses_attr, combinator_attr, legacy_prefix=None):
"""Lit une liste de clauses {"champ", "operateur", "valeur"} depuis un
attribut JSON (clauses_attr). Repli sur l'ANCIEN format à 2 filtres
fixes (legacy_prefix + "_champ"/"_operateur"/"_valeur", et
legacy_prefix + "2_champ"/"2_operateur"/"2_valeur") si clauses_attr est
absent — pour ne rien casser sur les éléments créés avant l'ajout des
clauses illimitées (aucune migration de données nécessaire, la
conversion se fait à la volée, ici, à la lecture). Renvoie toujours
(clauses, combinateur), combinateur valant "et" par défaut."""
raw = attrs.get(clauses_attr)
clauses = None
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
clauses = parsed
except (TypeError, ValueError):
clauses = None
if clauses is None and legacy_prefix:
clauses = []
for suffix in ("", "2"):
champ = (attrs.get(f"{legacy_prefix}{suffix}_champ") or "").strip()
if champ:
clauses.append({
"champ": champ,
"operateur": attrs.get(f"{legacy_prefix}{suffix}_operateur") or "egal",
"valeur": attrs.get(f"{legacy_prefix}{suffix}_valeur") or "",
})
combinateur = attrs.get(combinator_attr) or "et"
return clauses or [], combinateur
def encode_clauses(clauses):
"""Sérialise une liste de clauses en JSON, en retirant celles sans nom
de champ (une ligne ajoutée puis jamais remplie) — "" si la liste
nettoyée est vide, pour ne rien écrire de superflu en base."""
cleaned = [c for c in clauses if (c.get("champ") or "").strip()]
return json.dumps(cleaned) if cleaned else ""
+2 -1
View File
@@ -3,7 +3,8 @@ _flow_nodes/_flow_edges (screens/flow/) — jamais dans db/custom_events/,
pour la même raison que screens/elements/delete_element.py n'est pas
dans db/ : ce sont deux domaines différents (une entité game-wide, des
nœuds de graphe), le nettoyage croisé vit dans screens/, pas db/."""
from .list_custom_event_usages import list_custom_event_usages
from .delete_custom_event import delete_custom_event
from .list_custom_event_usages import list_custom_event_usages
__all__ = ["list_custom_event_usages", "delete_custom_event"]
+2 -1
View File
@@ -1,8 +1,9 @@
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
def delete_custom_event(slug, event_id):
def delete_custom_event(slug: str, event_id: int) -> None:
"""Seule suppression d'entité game-wide du moteur qui cascade
RÉELLEMENT (demande explicite) : une variable globale ou une
définition d'objet supprimée laisse des références pendantes
@@ -1,8 +1,11 @@
from typing import Any
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
def list_custom_event_usages(slug):
def list_custom_event_usages(slug: str) -> dict[int, list[dict[str, Any]]]:
"""Pour CHAQUE événement, où est-il écouté ("ecoute", nœud Déclencheur)
et déclenché ("declenche", nœud Action) — une seule requête plutôt
qu'une par événement, pour l'onglet Événements (screen_edit.html) qui
@@ -28,10 +31,14 @@ def list_custom_event_usages(slug):
"""
).fetchall()
conn.close()
usages = {}
usages: dict[int, list[dict[str, Any]]] = {}
for row in rows:
usages.setdefault(row["event_id"], []).append({
"screen_id": row["screen_id"], "screen_name": row["screen_name"],
"is_template": bool(row["is_template"]), "role": row["role"],
})
usages.setdefault(row["event_id"], []).append(
{
"screen_id": row["screen_id"],
"screen_name": row["screen_name"],
"is_template": bool(row["is_template"]),
"role": row["role"],
}
)
return usages
+3 -1
View File
@@ -1,7 +1,9 @@
from typing import Any
import db
def apply_add_row_action(slug, action, player_id=db.PLAYER_SHARED):
def apply_add_row_action(slug: str, action: dict[str, Any], player_id: str = db.PLAYER_SHARED) -> int | None:
"""Exécute au moment du clic (mode jouable) une action "ajouter_ligne" :
crée une ligne VIDE dans l'objet visé (aucun champ renseigné ici —
c'est à un ou plusieurs nœuds "Modifier une donnée" enchaînés,
+4 -2
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .compute_operation import compute_new_value
def apply_data_action(slug, action, player_id=db.PLAYER_SHARED):
def apply_data_action(slug: str, action: dict[str, Any], player_id: str = db.PLAYER_SHARED) -> bool:
"""Exécute au moment du clic (mode jouable) une action "modifier_donnee" :
lit l'objet/la ligne/le champ visés dans l'action, calcule la nouvelle
valeur selon l'opération choisie, et l'enregistre — un vrai UPDATE SQL,
@@ -42,7 +44,7 @@ def apply_data_action(slug, action, player_id=db.PLAYER_SHARED):
return True
def _clamp_to_field_bounds(value, field_def):
def _clamp_to_field_bounds(value: Any, field_def: dict[str, Any]) -> Any:
"""2.2 — bornage automatique : ramène la valeur dans [min_value,
max_value] si l'un ou l'autre est réglé sur ce champ, pour qu'une
jauge (voir render_jauge.py) ne puisse jamais dépasser 100 % ni
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .compute_operation import compute_new_value
def apply_score_action(slug, action, player_id=db.PLAYER_SHARED):
def apply_score_action(slug: str, action: dict[str, Any], player_id: str = db.PLAYER_SHARED) -> bool:
"""Exécute au moment du clic (mode jouable) une action
"modifier_score" — même vocabulaire d'opérations que
apply_variable_action.py (DATA_OPERATIONS), appliqué au score de CE
+3 -1
View File
@@ -1,7 +1,9 @@
from typing import Any
import db
def apply_status_action(slug, action, player_id=db.PLAYER_SHARED):
def apply_status_action(slug: str, action: dict[str, Any], player_id: str = db.PLAYER_SHARED) -> bool:
"""Exécute au moment du clic (mode jouable) une action
"definir_statut_partie" — réutilise data_value (déjà un simple champ
texte générique sur le nœud action, comme pour "attendre") pour porter
@@ -1,9 +1,11 @@
from typing import Any
import db
from .compute_operation import compute_new_value
def apply_variable_action(slug, action, player_id=db.PLAYER_SHARED):
def apply_variable_action(slug: str, action: dict[str, Any], player_id: str = db.PLAYER_SHARED) -> bool:
"""Exécute au moment du clic (mode jouable) une action
"modifier_variable" — même logique/vocabulaire d'opérations que
apply_data_action.py (DATA_OPERATIONS), appliqué à une variable globale
+5 -2
View File
@@ -1,7 +1,8 @@
import random
from typing import Any
def compute_new_value(operation, current, raw_value, is_decimal):
def compute_new_value(operation: str, current: Any, raw_value: Any, is_decimal: bool) -> Any:
"""Calcule la nouvelle valeur d'un champ d'objet ou d'une variable
globale pour une opération de DATA_OPERATIONS (voir
screens/labels/data_operations.py) — logique partagée par
@@ -77,5 +78,7 @@ def compute_new_value(operation, current, raw_value, is_decimal):
lo, hi = 0, 0
if lo > hi:
lo, hi = hi, lo
return random.uniform(lo, hi) if is_decimal else random.randint(int(lo), int(hi))
if is_decimal:
return random.uniform(lo, hi) # nosec B311 # noqa: S311 - tirage aleatoire de jeu, jamais crypto # NOSONAR S2245
return random.randint(int(lo), int(hi)) # nosec B311 # noqa: S311 - tirage aleatoire de jeu, jamais crypto # NOSONAR S2245
raise ValueError(f"opération inconnue : {operation!r}")
@@ -1,7 +1,9 @@
from typing import Any
import db
def data_definition_options(slug, definition_id):
def data_definition_options(slug: str | None, definition_id: int | None) -> dict[str, list[dict[str, Any]]]:
"""Lignes et champs d'un objet de données, prêts à peupler un menu
déroulant — utilisé à la fois pour DEFINITIONS_DATA (tous les objets du
jeu, consommé côté client par les formulaires de la Logique de la
+2 -2
View File
@@ -1,13 +1,13 @@
import db
def add_flow_edge(slug, screen_id, from_node_id, from_port, to_node_id):
def add_flow_edge(slug: str, screen_id: int, from_node_id: int, from_port: str | None, to_node_id: int) -> int:
conn = db.connect(slug)
conn.execute(
"INSERT INTO _flow_edges (screen_id, from_node_id, from_port, to_node_id) VALUES (?, ?, ?, ?)",
(screen_id, from_node_id, from_port or "out", to_node_id),
)
edge_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
edge_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return edge_id
+8 -6
View File
@@ -1,21 +1,23 @@
from typing import Any
import db
from .ensure_flow_schema import ensure_flow_schema
from .constants import FLOW_NODE_FIELDS
from .ensure_flow_schema import ensure_flow_schema
def add_flow_node(slug, screen_id, node_type, x=40, y=40, **fields):
def add_flow_node(slug: str, screen_id: int, node_type: str, x: float = 40, y: float = 40, **fields: Any) -> int:
ensure_flow_schema(slug)
cols = ["screen_id", "node_type", "x", "y"]
vals = [screen_id, node_type, x, y]
cols: list[str] = ["screen_id", "node_type", "x", "y"]
vals: list[Any] = [screen_id, node_type, x, y]
for k, v in fields.items():
if k in FLOW_NODE_FIELDS and v not in (None, ""):
cols.append(k)
vals.append(v)
placeholders = ", ".join("?" for _ in cols)
conn = db.connect(slug)
conn.execute(f"INSERT INTO _flow_nodes ({', '.join(cols)}) VALUES ({placeholders})", vals)
node_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.execute(f"INSERT INTO _flow_nodes ({', '.join(cols)}) VALUES ({placeholders})", vals) # nosec B608 # noqa: S608 - cols = liste fixe + cles filtrees contre FLOW_NODE_FIELDS (whitelist), jamais une cle arbitraire ; valeurs parametrees (?)
node_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return node_id
+15 -8
View File
@@ -1,4 +1,9 @@
def blocks_view(flow_blocks, flow_nodes, elements_labels):
from typing import Any
def blocks_view(
flow_blocks: list[dict[str, Any]], flow_nodes: list[dict[str, Any]], elements_labels: dict[int, str]
) -> list[dict[str, Any]]:
"""Enrichit chaque bloc (nom/description bruts) avec son nombre de
nœuds et les éléments qu'il concerne (trigger_element_id/
trigger_object_id des déclencheurs + target_element_id/
@@ -7,19 +12,21 @@ def blocks_view(flow_blocks, flow_nodes, elements_labels):
requête SQL supplémentaire. trigger_object_id/target_object_id
(fondations multi-éditeurs, objets de scène) sont toujours None sur
un jeu "document" — sans effet sur son comportement existant."""
nodes_by_block = {}
nodes_by_block: dict[Any, list[dict[str, Any]]] = {}
for n in flow_nodes:
nodes_by_block.setdefault(n.get("block_id"), []).append(n)
view = []
view: list[dict[str, Any]] = []
for b in flow_blocks:
block_nodes = nodes_by_block.get(b["id"], [])
element_ids = {n["trigger_element_id"] for n in block_nodes if n.get("trigger_element_id")}
element_ids |= {n["target_element_id"] for n in block_nodes if n.get("target_element_id")}
element_ids |= {n["trigger_object_id"] for n in block_nodes if n.get("trigger_object_id")}
element_ids |= {n["target_object_id"] for n in block_nodes if n.get("target_object_id")}
view.append({
**b,
"node_count": len(block_nodes),
"element_labels": [elements_labels[eid] for eid in element_ids if eid in elements_labels],
})
view.append(
{
**b,
"node_count": len(block_nodes),
"element_labels": [elements_labels[eid] for eid in element_ids if eid in elements_labels],
}
)
return view
+2 -2
View File
@@ -3,7 +3,7 @@ import db
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
def create_flow_block(slug, screen_id, name, description=""):
def create_flow_block(slug: str, screen_id: int, name: str, description: str = "") -> int | None:
name = (name or "").strip()
if not name:
return None
@@ -13,7 +13,7 @@ def create_flow_block(slug, screen_id, name, description=""):
"INSERT INTO _flow_blocks (screen_id, name, description) VALUES (?, ?, ?)",
(screen_id, name, (description or "").strip()),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
new_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return new_id
+1 -1
View File
@@ -4,7 +4,7 @@ from ..ensure_flow_schema import ensure_flow_schema
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
def delete_flow_block(slug, block_id):
def delete_flow_block(slug: str, block_id: int) -> None:
"""Supprime le bloc ET tout ce qu'il contient (nœuds + arêtes) — décision
explicite de l'utilisateur, un bloc de logique se comporte comme une
fonction qu'on supprime avec son corps. Patron identique à
@@ -1,7 +1,7 @@
import db
def ensure_flow_blocks_schema(slug):
def ensure_flow_blocks_schema(slug: str) -> None:
"""Un bloc de logique regroupe les nœuds d'UN écran (scène ou modèle)
en sous-graphe nommé/décrit, pour rester lisible même quand un écran
accumule des milliers de nœuds — voir _flow_nodes.block_id
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
def get_flow_block(slug, block_id):
def get_flow_block(slug: str, block_id: int | None) -> dict[str, Any] | None:
if not block_id:
return None
ensure_flow_blocks_schema(slug)
+4 -4
View File
@@ -1,3 +1,5 @@
from typing import Any
import db
from ..ensure_flow_schema import ensure_flow_schema
@@ -6,7 +8,7 @@ from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
DEFAULT_BLOCK_NAME = "Bloc principal"
def list_flow_blocks(slug, screen_id):
def list_flow_blocks(slug: str, screen_id: int) -> list[dict[str, Any]]:
"""Migration douce : un écran qui a des nœuds posés AVANT l'existence
des blocs (block_id NULL) se voit attribuer, à la première consultation
de l'onglet Blocs, un bloc "Bloc principal" auto-créé qui les regroupe —
@@ -38,8 +40,6 @@ def list_flow_blocks(slug, screen_id):
(default_block_id, screen_id),
)
conn.commit()
rows = conn.execute(
"SELECT * FROM _flow_blocks WHERE screen_id = ? ORDER BY id", (screen_id,)
).fetchall()
rows = conn.execute("SELECT * FROM _flow_blocks WHERE screen_id = ? ORDER BY id", (screen_id,)).fetchall()
conn.close()
return [dict(r) for r in rows]
+1 -1
View File
@@ -3,7 +3,7 @@ import db
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
def update_flow_block(slug, block_id, name, description=""):
def update_flow_block(slug: str, block_id: int, name: str, description: str = "") -> None:
name = (name or "").strip()
if not name:
return
+31 -10
View File
@@ -103,19 +103,36 @@ CONDITION_OPERATORS = [
CONDITION_OPERATOR_LABELS = dict(CONDITION_OPERATORS)
FLOW_NODE_FIELDS = {
"trigger_element_id", "trigger_event",
"cond_definition_id", "cond_row_id", "cond_field", "cond_field_type", "cond_operator", "cond_value",
"trigger_element_id",
"trigger_event",
"cond_definition_id",
"cond_row_id",
"cond_field",
"cond_field_type",
"cond_operator",
"cond_value",
# 2.4 — conditions combinées (ET/OU) : cond_clauses est une liste JSON de
# clauses supplémentaires (en plus de la clause "historique" ci-dessus,
# qui reste la première clause) + cond_combinator ('et'/'ou') pour savoir
# comment les combiner. Absents => comportement legacy (une seule clause).
"cond_clauses", "cond_combinator",
"cond_clauses",
"cond_combinator",
# Condition sur une VARIABLE GLOBALE plutôt qu'un champ d'objet — voir
# ensure_flow_schema.py pour le détail des 3 clés (cond_source vaut
# "objet" ou "variable" ; absent => "objet", comportement historique).
"cond_source", "cond_variable", "cond_variable_chemin",
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
"cond_source",
"cond_variable",
"cond_variable_chemin",
"action_type",
"target_screen_id",
"target_element_id",
"element_property",
"element_value",
"target_definition_id",
"target_row_id",
"target_field",
"data_operation",
"data_value",
"target_variable",
# Événements personnalisés (voir screens/custom_events/) : quel
# événement un nœud Déclencheur écoute (trigger_event="evenement") ou
@@ -125,7 +142,8 @@ FLOW_NODE_FIELDS = {
# (déclencheur → condition → action) de décider quoi faire ensuite,
# avec ses propres réglages habituels (target_element_id normal, pas
# de mécanisme dynamique dédié).
"trigger_custom_event_id", "target_custom_event_id",
"trigger_custom_event_id",
"target_custom_event_id",
# Blocs de logique (voir screens/flow/blocks/) : à quel bloc ce nœud
# appartient. Aucun `update_flow_node` n'existe (éditer un nœud le
# recrée puis supprime l'ancien) — le client doit donc renvoyer
@@ -133,14 +151,17 @@ FLOW_NODE_FIELDS = {
"block_id",
# Phase 3 — déclencheur clavier ("clavier") et minuteur récurrent
# ("minuteur"), voir TRIGGER_EVENTS ci-dessus.
"trigger_key", "trigger_interval_ms",
"trigger_key",
"trigger_interval_ms",
# Phase 5 — condition de collision (cond_source="collision", aux
# côtés de "objet"/"variable") : les deux éléments dont on compare les
# rectangles — voir evaluateConditionClause() dans
# static/js/play/conditions.js.
"cond_element_a", "cond_element_b",
"cond_element_a",
"cond_element_b",
# Fondations multi-éditeurs — jeux "jeu_2d" : équivalents de
# trigger_element_id/target_element_id pour un objet de scène (voir
# ensure_flow_schema.py).
"trigger_object_id", "target_object_id",
"trigger_object_id",
"target_object_id",
}
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def delete_flow_edge(slug, edge_id):
def delete_flow_edge(slug: str, edge_id: int) -> None:
conn = db.connect(slug)
conn.execute("DELETE FROM _flow_edges WHERE id = ?", (edge_id,))
conn.commit()
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def delete_flow_node(slug, node_id):
def delete_flow_node(slug: str, node_id: int) -> None:
conn = db.connect(slug)
conn.execute("DELETE FROM _flow_edges WHERE from_node_id = ? OR to_node_id = ?", (node_id, node_id))
conn.execute("DELETE FROM _flow_nodes WHERE id = ?", (node_id,))
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def ensure_flow_schema(slug):
def ensure_flow_schema(slug: str) -> None:
conn = db.connect(slug)
conn.executescript(
"""
+3 -1
View File
@@ -1,7 +1,9 @@
from typing import Any
import db
def get_flow_node(slug, node_id):
def get_flow_node(slug: str, node_id: int) -> dict[str, Any] | None:
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _flow_nodes WHERE id = ?", (node_id,)).fetchone()
conn.close()
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .ensure_flow_schema import ensure_flow_schema
def list_flow_edges(slug, screen_id):
def list_flow_edges(slug: str, screen_id: int) -> list[dict[str, Any]]:
ensure_flow_schema(slug)
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _flow_edges WHERE screen_id = ? ORDER BY id", (screen_id,)).fetchall()
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .ensure_flow_schema import ensure_flow_schema
def list_flow_nodes(slug, screen_id):
def list_flow_nodes(slug: str, screen_id: int) -> list[dict[str, Any]]:
ensure_flow_schema(slug)
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _flow_nodes WHERE screen_id = ? ORDER BY id", (screen_id,)).fetchall()
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def move_flow_node(slug, node_id, x, y):
def move_flow_node(slug: str, node_id: int, x: float, y: float) -> None:
conn = db.connect(slug)
conn.execute("UPDATE _flow_nodes SET x = ?, y = ? WHERE id = ?", (x, y, node_id))
conn.commit()
+3 -5
View File
@@ -6,7 +6,7 @@ _DEFAULT_TITLE = "Nouvelle conversation"
_TITLE_MAX_LENGTH = 50
def add_ia_chat_message(slug, conversation_id, role, content):
def add_ia_chat_message(slug: str, conversation_id: int, role: str, content: str) -> int:
"""Ajoute un message (role "user" ou "assistant") à une conversation
— voir routes/ia/ia_chat.py, seul appelant.
@@ -20,11 +20,9 @@ def add_ia_chat_message(slug, conversation_id, role, content):
"INSERT INTO _ia_chat_messages (conversation_id, role, content) VALUES (?, ?, ?)",
(conversation_id, role, content),
)
message_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
message_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
if role == "user":
conversation = conn.execute(
"SELECT title FROM _ia_conversations WHERE id = ?", (conversation_id,)
).fetchone()
conversation = conn.execute("SELECT title FROM _ia_conversations WHERE id = ?", (conversation_id,)).fetchone()
if conversation and conversation["title"] == _DEFAULT_TITLE:
title = content.strip()[:_TITLE_MAX_LENGTH]
conn.execute("UPDATE _ia_conversations SET title = ? WHERE id = ?", (title, conversation_id))
+3 -3
View File
@@ -3,14 +3,14 @@ import db
from .ensure_ia_chat_schema import ensure_ia_chat_schema
def create_ia_conversation(slug, screen_id):
""""+ Nouvelle conversation" (voir static/js/ia/ia-chat.js) : créée
def create_ia_conversation(slug: str, screen_id: int) -> int:
""" "+ Nouvelle conversation" (voir static/js/ia/ia-chat.js) : créée
IMMÉDIATEMENT, vide — visible tout de suite dans la colonne, pas
besoin d'attendre un premier message."""
ensure_ia_chat_schema(slug)
conn = db.connect(slug)
conn.execute("INSERT INTO _ia_conversations (screen_id) VALUES (?)", (screen_id,))
conversation_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conversation_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return conversation_id
+1 -1
View File
@@ -3,7 +3,7 @@ import db
from .ensure_ia_chat_schema import ensure_ia_chat_schema
def delete_ia_conversation(slug, conversation_id):
def delete_ia_conversation(slug: str, conversation_id: int) -> None:
"""Cascade sur les messages (ON DELETE CASCADE, voir
ensure_ia_chat_schema.py)."""
ensure_ia_chat_schema(slug)
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def ensure_ia_chat_schema(slug):
def ensure_ia_chat_schema(slug: str) -> None:
"""Chat "Générer avec l'IA" — plusieurs CONVERSATIONS par écran
(demande explicite : gérer plusieurs fils, comme claude.ai), chacune
portant sa propre liste de messages. ON DELETE CASCADE à chaque
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .ensure_ia_chat_schema import ensure_ia_chat_schema
def get_ia_conversation(slug, conversation_id):
def get_ia_conversation(slug: str, conversation_id: int) -> dict[str, Any] | None:
"""Renvoie aussi screen_id — indispensable pour vérifier
l'appartenance à CE jeu avant de supprimer/lire/écrire (voir
routes/ia/ia_chat.py), même patron que auth.get_user_asset."""
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .ensure_ia_chat_schema import ensure_ia_chat_schema
def list_ia_chat_messages(slug, conversation_id):
def list_ia_chat_messages(slug: str, conversation_id: int) -> list[dict[str, Any]]:
"""Historique d'UNE conversation, dans l'ordre chronologique (voir
static/js/ia/ia-chat.js, qui les affiche tels quels)."""
ensure_ia_chat_schema(slug)
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .ensure_ia_chat_schema import ensure_ia_chat_schema
def list_ia_conversations(slug, screen_id):
def list_ia_conversations(slug: str, screen_id: int) -> list[dict[str, Any]]:
"""Conversations d'un écran, plus récente en premier (voir la
colonne de gauche de l'onglet "Générer avec l'IA")."""
ensure_ia_chat_schema(slug)
+12 -8
View File
@@ -1,5 +1,6 @@
import json
import os
from typing import Any
# Bibliothèque de sprites animaux CraftPix — RÉSERVÉE au rôle admin (voir
# routes/screens/screen_edit.py, routes/scenes/scene_edit_view.py, et les
@@ -13,14 +14,17 @@ import os
# les packs — pas de table figée comme _SEQUENCE_ANIMATIONS pour Kenney).
_MANIFEST_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"static", "characters", "animals", "manifest.json",
"static",
"characters",
"animals",
"manifest.json",
)
def _load_manifest():
def _load_manifest() -> dict[str, Any]:
try:
with open(_MANIFEST_PATH, encoding="utf-8") as f:
return json.load(f)
return dict(json.load(f))
except FileNotFoundError:
# Poste de dev sans les assets animaux copiés (script pas encore
# lancé), ou paquet exporté pour un jeu qui n'utilise aucun
@@ -29,25 +33,25 @@ def _load_manifest():
return {}
def _slug(animal, nn):
def _slug(animal: str, nn: str) -> str:
return f"animal-{animal}-{nn}"
def _label(family_label, nn):
def _label(family_label: str, nn: str) -> str:
n = int(nn)
return f"{family_label} (variante {n})" if n > 1 else f"{family_label} (de base)"
def _animations_for(animal, nn, poses):
def _animations_for(animal: str, nn: str, poses: dict[str, int]) -> dict[str, list[str]]:
return {
pose: [f"/static/characters/animals/{animal}/{nn}/{pose}{i}.png" for i in range(count)]
for pose, count in poses.items()
}
def _build_library():
def _build_library() -> dict[str, dict[str, Any]]:
manifest = _load_manifest()
library = {}
library: dict[str, dict[str, Any]] = {}
for animal, pack in manifest.items():
family_label = pack["label"]
for nn, poses in pack["variants"].items():
+8 -5
View File
@@ -1,5 +1,6 @@
import json
import os
from typing import Any
# Bibliothèque d'images de fond CraftPix — RÉSERVÉE au rôle admin (voir
# core/sprite_gate.py, routes/scenes/scene_object_add.py), même règle que
@@ -14,14 +15,16 @@ import os
# modifiable ensuite comme n'importe quel objet (panneau "Position & taille").
_MANIFEST_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"static", "backgrounds", "manifest.json",
"static",
"backgrounds",
"manifest.json",
)
def _load_manifest():
def _load_manifest() -> dict[str, Any]:
try:
with open(_MANIFEST_PATH, encoding="utf-8") as f:
return json.load(f)
return dict(json.load(f))
except FileNotFoundError:
# Poste de dev sans les images de fond copiées (script pas encore
# lancé), ou paquet exporté pour un jeu qui n'utilise aucun fond
@@ -31,9 +34,9 @@ def _load_manifest():
return {}
def _build_library():
def _build_library() -> dict[str, dict[str, Any]]:
manifest = _load_manifest()
library = {}
library: dict[str, dict[str, Any]] = {}
for pack_slug, pack in manifest.items():
pack_label = pack["label"]
for image_name, dims in pack["images"].items():
+6 -3
View File
@@ -1,4 +1,7 @@
def sprite_gallery_families(sprite_library):
from typing import Any
def sprite_gallery_families(sprite_library: dict[str, dict[str, Any]]) -> list[tuple[str, dict[str, Any]]]:
"""Un tile par FAMILLE pour la galerie d'ajout/de changement de
personnage — les personnages Kenney (pas de clé "family", voir
screens/labels/sprite_library.py) sont chacun leur propre famille ;
@@ -9,8 +12,8 @@ def sprite_gallery_families(sprite_library):
panneau de propriétés (voir render_variant_gallery, templates/
screen_edit.html) plutôt que d'encombrer cette galerie de 15 tiles
quasi identiques par animal."""
seen_families = set()
families = []
seen_families: set[str] = set()
families: list[tuple[str, dict[str, Any]]] = []
for slug, character in sprite_library.items():
family = character.get("family")
if family:
+31 -11
View File
@@ -23,22 +23,43 @@ CHARACTERS = {
# pour les 6 personnages (confirmé sur disque), donc une seule définition
# suffit pour tous.
_SEQUENCE_ANIMATIONS = {
"walk": 8, # cycle de marche
"run": 3, # course
"walk": 8, # cycle de marche
"run": 3, # course
"attack": 3, # attaque
"cheer": 2, # acclamation
"climb": 2, # escalade
"cheer": 2, # acclamation
"climb": 2, # escalade
"switch": 2, # bascule/actionner
}
_SINGLE_POSES = [
"idle", "jump", "fall", "duck", "hit", "back", "slide", "interact",
"kick", "side", "shove", "shoveBack", "talk", "attackKick", "hang",
"hold", "hurt", "behindBack", "down", "drag", "fallDown", "rope",
"show", "think", "wide",
"idle",
"jump",
"fall",
"duck",
"hit",
"back",
"slide",
"interact",
"kick",
"side",
"shove",
"shoveBack",
"talk",
"attackKick",
"hang",
"hold",
"hurt",
"behindBack",
"down",
"drag",
"fallDown",
"rope",
"show",
"think",
"wide",
]
def _animations_for(slug):
def _animations_for(slug: str) -> dict[str, list[str]]:
animations = {
name: [f"/static/characters/{slug}/{name}{i}.png" for i in range(count)]
for name, count in _SEQUENCE_ANIMATIONS.items()
@@ -54,6 +75,5 @@ def _animations_for(slug):
# screens/labels/animal_sprite_library.py et le plan "Bibliothèque de
# sprites animaux CraftPix").
PUBLIC_SPRITE_LIBRARY = {
slug: {"label": label, "animations": _animations_for(slug)}
for slug, label in CHARACTERS.items()
slug: {"label": label, "animations": _animations_for(slug)} for slug, label in CHARACTERS.items()
}
+22 -16
View File
@@ -1,23 +1,25 @@
from typing import Any
import db
from ..screens_repo.list_screens import list_screens
from ..flow.list_flow_nodes import list_flow_nodes
from ..flow.list_flow_edges import list_flow_edges
from ..animations.list_animation_clips import list_animation_clips
from ..rendering.personnage_data import resolve_personnage_animations
from ..rendering.personnage_commands import resolve_personnage_commands
from ..rendering.personnage_role import resolve_personnage_role
from ..rendering.collision_settings import resolve_collision_settings
from ..flow.list_flow_edges import list_flow_edges
from ..flow.list_flow_nodes import list_flow_nodes
from ..rendering.collision_rules import resolve_collision_rules
from ..rendering.screen_triggers import resolve_screen_triggers
from ..rendering.collision_settings import resolve_collision_settings
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
from ..rendering.personnage_commands import resolve_personnage_commands
from ..rendering.personnage_data import resolve_personnage_animations
from ..rendering.personnage_role import resolve_personnage_role
from ..rendering.quiz_box_config import resolve_quiz_box_config
from ..rendering.screen_triggers import resolve_screen_triggers
from ..rendering.trigger_graph import list_completable_dialogue_ids
from ..scenes.list_scene_objects import list_scene_objects
from ..scenes.render_scene_object import render_scene_object
from ..screens_repo.list_screens import list_screens
def full_game_payload(slug, player_id=db.PLAYER_SHARED):
def full_game_payload(slug: str, player_id: str = db.PLAYER_SHARED) -> dict[str, Any]:
"""Toutes les données nécessaires au runtime JS de la page de jeu jouable
(/game/<slug>/play, ou le paquet Web/SCORM exporté — voir
publish/build_scorm_package.py) : chaque écran avec ses éléments (HTML
@@ -134,6 +136,9 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
fields_meta = {}
for d in definitions:
full = db.get_definition(slug, d["id"])
assert full is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
"d['id'] vient de list_definitions(slug), donc get_definition ne peut pas renvoyer None ici"
)
rows = db.list_rows(slug, full, player_id)
# Un champ "relation" vit dans une colonne SQL "<champ>_id", jamais
# sous son nom "propre" (voir _field_column dans
@@ -152,10 +157,7 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
if f["type"] == "relation":
col += "_id"
cols[f["name"]] = col
data[str(d["id"])] = [
{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]}
for r in rows
]
data[str(d["id"])] = [{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]} for r in rows]
# min_value/max_value (2.2 — bornage automatique, voir
# apply_data_action.py) : nécessaires ici pour que le port
# client-side de l'export Web/SCORM (static/js/play/offline/,
@@ -194,9 +196,13 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
completable_dialogue_ids = list_completable_dialogue_ids(slug)
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"data": data, "fields_meta": fields_meta,
"variables": variables, "personnage_animations": personnage_animations,
"screens": payload_screens,
"flows": flows,
"animations": animations,
"data": data,
"fields_meta": fields_meta,
"variables": variables,
"personnage_animations": personnage_animations,
"definition_names": definition_names,
"completable_dialogue_ids": completable_dialogue_ids,
}
+64 -54
View File
@@ -1,5 +1,6 @@
import json
import uuid
from typing import Any, Callable
from db.dialogue_lines import sanitize_dialogue_lines
from screens.flow.constants import CONDITION_OPERATORS
@@ -110,8 +111,16 @@ from screens.labels.video_modes import VIDEO_MODE_LABELS
# ("attaque"/"evenement" retirés — ne servaient à rien pour l'instant,
# demande explicite.)
ACTION_TYPES = [
"dialogue", "variable", "surbrillance", "visibilite", "son", "video", "indication", "attendre",
"interagir", "condition",
"dialogue",
"variable",
"surbrillance",
"visibilite",
"son",
"video",
"indication",
"attendre",
"interagir",
"condition",
]
TRIGGER_TYPES = ["collision", "clic", "survol"]
CONDITION_OPERATOR_KEYS = {key for key, _label in CONDITION_OPERATORS}
@@ -139,7 +148,7 @@ _MAX_ACTION_ID_LENGTH = 40
_NO_VALUE_OPERATIONS = ("definir_bool_vrai", "definir_bool_faux", "basculer_bool")
def _sanitize_dialogue(raw_dialogue):
def _sanitize_dialogue(raw_dialogue: Any) -> dict[str, Any] | None:
if not isinstance(raw_dialogue, dict):
return None
dialogue_id = raw_dialogue.get("id")
@@ -151,8 +160,8 @@ def _sanitize_dialogue(raw_dialogue):
}
def _sanitize_dialogue_action(raw_action):
action = {"type": "dialogue"}
def _sanitize_dialogue_action(raw_action: dict[str, Any]) -> dict[str, Any]:
action: dict[str, Any] = {"type": "dialogue"}
dialogue = _sanitize_dialogue(raw_action.get("dialogue"))
if dialogue:
action["dialogue"] = dialogue
@@ -161,14 +170,18 @@ def _sanitize_dialogue_action(raw_action):
return action
def _sanitize_variable_action(raw_action):
def _sanitize_variable_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
target_variable = raw_action.get("target_variable")
if not isinstance(target_variable, str) or not target_variable.strip():
return None
data_operation = raw_action.get("data_operation")
if data_operation not in DATA_OPERATION_LABELS:
return None
action = {"type": "variable", "target_variable": target_variable.strip(), "data_operation": data_operation}
action: dict[str, Any] = {
"type": "variable",
"target_variable": target_variable.strip(),
"data_operation": data_operation,
}
action_id = raw_action.get("id")
if isinstance(action_id, str) and action_id.strip():
action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH]
@@ -181,15 +194,17 @@ def _sanitize_variable_action(raw_action):
return action
def _sanitize_object_id(raw_action):
def _sanitize_object_id(raw_action: dict[str, Any]) -> int | None:
object_id = raw_action.get("object_id")
if object_id is None:
return None
try:
return int(object_id)
except (TypeError, ValueError):
return None
def _with_optional_id(action, raw_action):
def _with_optional_id(action: dict[str, Any], raw_action: dict[str, Any]) -> dict[str, Any]:
"""Ajoute le champ `id` (optionnel, généré côté client) commun à
toutes les feuilles autres que "dialogue" — sert à adresser CETTE
feuille précise pour y attacher un "then" plus tard (voir
@@ -200,7 +215,7 @@ def _with_optional_id(action, raw_action):
return action
def _sanitize_surbrillance_action(raw_action):
def _sanitize_surbrillance_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
object_id = _sanitize_object_id(raw_action)
valeur = raw_action.get("valeur")
if object_id is None or valeur not in SURBRILLANCE_LABELS:
@@ -208,7 +223,7 @@ def _sanitize_surbrillance_action(raw_action):
return _with_optional_id({"type": "surbrillance", "object_id": object_id, "valeur": valeur}, raw_action)
def _sanitize_visibilite_action(raw_action):
def _sanitize_visibilite_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
object_id = _sanitize_object_id(raw_action)
valeur = raw_action.get("valeur")
if object_id is None or valeur not in ELEMENT_VISIBILITY_LABELS:
@@ -216,14 +231,14 @@ def _sanitize_visibilite_action(raw_action):
return _with_optional_id({"type": "visibilite", "object_id": object_id, "valeur": valeur}, raw_action)
def _sanitize_son_action(raw_action):
def _sanitize_son_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
asset_url = raw_action.get("asset_url")
if not isinstance(asset_url, str) or not asset_url.strip():
return None
return _with_optional_id({"type": "son", "asset_url": asset_url.strip()}, raw_action)
def _sanitize_video_action(raw_action):
def _sanitize_video_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
asset_url = raw_action.get("asset_url")
mode = raw_action.get("mode")
if not isinstance(asset_url, str) or not asset_url.strip() or mode not in VIDEO_MODE_LABELS:
@@ -231,26 +246,28 @@ def _sanitize_video_action(raw_action):
return _with_optional_id({"type": "video", "asset_url": asset_url.strip(), "mode": mode}, raw_action)
def _sanitize_indication_action(raw_action):
def _sanitize_indication_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
object_id = _sanitize_object_id(raw_action)
texte = raw_action.get("texte")
if object_id is None or not isinstance(texte, str) or not texte.strip():
return None
action = {"type": "indication", "object_id": object_id, "texte": texte.strip()[:200]}
action: dict[str, Any] = {"type": "indication", "object_id": object_id, "texte": texte.strip()[:200]}
duree_ms = raw_action.get("duree_ms")
if isinstance(duree_ms, (int, float)) and not isinstance(duree_ms, bool) and duree_ms > 0:
action["duree_ms"] = int(duree_ms)
return _with_optional_id(action, raw_action)
def _sanitize_attendre_action(raw_action):
""""Attendre N secondes" avant de continuer la chaîne — MÊME
def _sanitize_attendre_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
""" "Attendre N secondes" avant de continuer la chaîne — MÊME
vocabulaire que l'action "attendre" du Flow (voir static/js/play/
actions.js, `data_value` en SECONDES, pas en ms) : suspend le "then"
plutôt que de l'exécuter immédiatement (comme "video"), utile par ex.
pour une narration à l'affichage de l'écran qui doit laisser un temps
de lecture avant d'enchaîner."""
raw_value = raw_action.get("data_value")
if raw_value is None:
return None
try:
seconds = float(raw_value)
except (TypeError, ValueError):
@@ -263,7 +280,7 @@ def _sanitize_attendre_action(raw_action):
# Une seule table de vérité pour les feuilles : ajouter un type ici suffit
# (voir LEAF_ACTION_TYPES dérivé de ses clés) — évite le if/elif qui aurait
# dû être répété à chaque nouvel ajout.
_LEAF_ACTION_SANITIZERS = {
_LEAF_ACTION_SANITIZERS: dict[str, Callable[[dict[str, Any]], dict[str, Any] | None]] = {
"dialogue": _sanitize_dialogue_action,
"variable": _sanitize_variable_action,
"surbrillance": _sanitize_surbrillance_action,
@@ -276,14 +293,14 @@ _LEAF_ACTION_SANITIZERS = {
LEAF_ACTION_TYPES = tuple(_LEAF_ACTION_SANITIZERS)
def _sanitize_leaf_action(raw_action, depth=1):
def _sanitize_leaf_action(raw_action: Any, depth: int = 1) -> dict[str, Any] | None:
"""Sanitize une FEUILLE (voir LEAF_ACTION_TYPES) et son éventuelle
chaîne "then" (bornée à _MAX_THEN_CHAIN) — utilisée partout où une
feuille est attendue : action de premier niveau, sous "interagir", ou
branche Vrai/Faux de "condition"."""
if not isinstance(raw_action, dict):
return None
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type"))
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type") or "")
if not sanitizer:
return None
action = sanitizer(raw_action)
@@ -296,7 +313,7 @@ def _sanitize_leaf_action(raw_action, depth=1):
return action
def _sanitize_condition_branch(raw_branch):
def _sanitize_condition_branch(raw_branch: Any) -> dict[str, Any] | None:
"""Une branche Vrai/Faux de "condition" : soit None ("ne rien
faire", jamais un dict vide), soit une FEUILLE ("dialogue" ou
"variable", éventuellement chaînée via "then") — jamais "interagir"/
@@ -307,7 +324,7 @@ def _sanitize_condition_branch(raw_branch):
return _sanitize_leaf_action(raw_branch)
def _sanitize_action(raw_action, depth=1):
def _sanitize_action(raw_action: Any, depth: int = 1) -> dict[str, Any] | None:
if not isinstance(raw_action, dict):
return None
action_type = raw_action.get("type")
@@ -337,7 +354,7 @@ def _sanitize_action(raw_action, depth=1):
}
def sanitize_collision_rules(raw_rules, allowed_triggers=None):
def sanitize_collision_rules(raw_rules: Any, allowed_triggers: list[str] | None = None) -> list[dict[str, Any]]:
"""Valide/nettoie une liste de règles postée par le client (voir
routes/triggers/) — une règle invalide (déclencheur/action inconnus,
imbrication trop profonde...) est IGNORÉE plutôt que de faire planter
@@ -350,7 +367,7 @@ def sanitize_collision_rules(raw_rules, allowed_triggers=None):
allowed_triggers = TRIGGER_TYPES
if not isinstance(raw_rules, list):
return []
cleaned = []
cleaned: list[dict[str, Any]] = []
for raw_rule in raw_rules[:50]: # marge large, jamais illimité
if not isinstance(raw_rule, dict):
continue
@@ -364,7 +381,7 @@ def sanitize_collision_rules(raw_rules, allowed_triggers=None):
return cleaned
def _apply_dialogue_lines(action, dialogue_id, lines):
def _apply_dialogue_lines(action: Any, dialogue_id: str, lines: list[dict[str, Any]]) -> bool:
if not isinstance(action, dict):
return False
action_type = action.get("type")
@@ -379,14 +396,13 @@ def _apply_dialogue_lines(action, dialogue_id, lines):
if action_type == "interagir":
return _apply_dialogue_lines(action.get("sub_action"), dialogue_id, lines)
if action_type == "condition":
return (
_apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines)
or _apply_dialogue_lines(action.get("si_faux"), dialogue_id, lines)
return _apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines) or _apply_dialogue_lines(
action.get("si_faux"), dialogue_id, lines
)
return False
def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines):
def set_dialogue_lines_in_rules(rules: list[dict[str, Any]], dialogue_id: str, raw_lines: Any) -> bool:
"""Met à jour EN PLACE les répliques du dialogue `dialogue_id`,
où qu'il se trouve dans l'arbre d'action d'une des règles (direct,
chaîné via "then", sous "interagir", ou dans une branche de
@@ -395,13 +411,10 @@ def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines):
rien modifier si `dialogue_id` n'existe dans aucune règle (référence
orpheline, ex. règle supprimée entre-temps)."""
lines = sanitize_dialogue_lines(raw_lines)
for rule in rules:
if _apply_dialogue_lines(rule.get("action"), dialogue_id, lines):
return True
return False
return any(_apply_dialogue_lines(rule.get("action"), dialogue_id, lines) for rule in rules)
def _leaf_id(action):
def _leaf_id(action: dict[str, Any]) -> str | None:
if action.get("type") == "dialogue":
dialogue = action.get("dialogue")
return dialogue.get("id") if isinstance(dialogue, dict) else None
@@ -410,7 +423,7 @@ def _leaf_id(action):
return None
def _apply_then_action(action, leaf_id, then_action):
def _apply_then_action(action: Any, leaf_id: str, then_action: dict[str, Any]) -> bool:
if not isinstance(action, dict):
return False
action_type = action.get("type")
@@ -431,14 +444,13 @@ def _apply_then_action(action, leaf_id, then_action):
if action_type == "interagir":
return _apply_then_action(action.get("sub_action"), leaf_id, then_action)
if action_type == "condition":
return (
_apply_then_action(action.get("si_vrai"), leaf_id, then_action)
or _apply_then_action(action.get("si_faux"), leaf_id, then_action)
return _apply_then_action(action.get("si_vrai"), leaf_id, then_action) or _apply_then_action(
action.get("si_faux"), leaf_id, then_action
)
return False
def set_then_action_in_rules(rules, leaf_id, raw_then_action):
def set_then_action_in_rules(rules: list[dict[str, Any]], leaf_id: str, raw_then_action: Any) -> bool:
"""INSÈRE EN PLACE une NOUVELLE action juste après la feuille
identifiée par `leaf_id` (dialogue.id pour une feuille dialogue, id
pour toute autre feuille) — n'importe où dans l'arbre d'action d'une
@@ -452,10 +464,7 @@ def set_then_action_in_rules(rules, leaf_id, raw_then_action):
then_action = _sanitize_leaf_action(raw_then_action)
if not then_action:
return False
for rule in rules:
if _apply_then_action(rule.get("action"), leaf_id, then_action):
return True
return False
return any(_apply_then_action(rule.get("action"), leaf_id, then_action) for rule in rules)
# ---------- Suppression/réordonnancement d'un maillon précis (demande
@@ -468,7 +477,8 @@ def set_then_action_in_rules(rules, leaf_id, raw_then_action):
# RÈGLE elle-même doit être remplacée ou retirée, pas juste un champ
# interne).
def _remove_leaf_id(action, leaf_id):
def _remove_leaf_id(action: Any, leaf_id: str) -> tuple[Any, bool]:
"""Retire la feuille `leaf_id` de l'arbre `action` — renvoie
(nouvel_arbre_ou_None, trouvé). `nouvel_arbre` vaut None si `action`
lui-même était la cible et n'avait rien pour le remplacer (ni "then",
@@ -511,7 +521,7 @@ def _remove_leaf_id(action, leaf_id):
return action, False
def remove_action_from_rules(rules, leaf_id):
def remove_action_from_rules(rules: list[dict[str, Any]], leaf_id: str) -> bool:
"""Retire EN PLACE l'action `leaf_id` n'importe où dans l'arbre d'une
des règles (feuille directe d'une règle, maillon d'une chaîne "then",
sous-action d'un "interagir", ou dans une branche de "condition"). Si
@@ -531,12 +541,12 @@ def remove_action_from_rules(rules, leaf_id):
return False
def _chain_as_list(head_action):
def _chain_as_list(head_action: Any) -> list[dict[str, Any]]:
"""Aplatit une chaîne de FEUILLES pures (voir LEAF_ACTION_TYPES) en
liste — le réordonnancement ne s'applique qu'à l'intérieur d'une
chaîne "then" (jamais entre deux branches différentes d'une
condition, ni entre une "interagir" et ce qui l'entoure)."""
items = []
items: list[dict[str, Any]] = []
node = head_action
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
items.append(node)
@@ -544,7 +554,7 @@ def _chain_as_list(head_action):
return items
def _relink_chain(items):
def _relink_chain(items: list[dict[str, Any]]) -> dict[str, Any] | None:
items = [dict(n) for n in items]
for i in range(len(items) - 1):
items[i]["then"] = items[i + 1]
@@ -553,7 +563,7 @@ def _relink_chain(items):
return items[0] if items else None
def _move_in_action_tree(action, leaf_id, direction):
def _move_in_action_tree(action: Any, leaf_id: str, direction: str) -> dict[str, Any] | None:
"""Cherche, dans `action`, LA chaîne "then" qui contient `leaf_id`
(au premier niveau, sous une "interagir", ou dans une branche de
"condition"), et échange `leaf_id` avec son voisin immédiat
@@ -593,7 +603,7 @@ def _move_in_action_tree(action, leaf_id, direction):
return None
def move_action_in_rules(rules, leaf_id, direction):
def move_action_in_rules(rules: list[dict[str, Any]], leaf_id: str, direction: str) -> bool:
"""Déplace EN PLACE la feuille `leaf_id` d'un cran vers le haut
("up") ou le bas ("down") DANS SA PROPRE CHAÎNE "then" (jamais
au-delà de sa branche de condition/sous-action interagir — voir
@@ -611,11 +621,11 @@ def move_action_in_rules(rules, leaf_id, direction):
return False
def _generate_leaf_id():
def _generate_leaf_id() -> str:
return "d_" + uuid.uuid4().hex[:12]
def _backfill_leaf_ids(action):
def _backfill_leaf_ids(action: Any) -> bool:
"""Assure EN PLACE qu'un id STABLE existe sur toute feuille non-
"dialogue" (qui a déjà le sien via dialogue.id) qui n'en avait pas —
ex. une action "variable" posée AVANT que ce champ ne devienne
@@ -647,7 +657,7 @@ def _backfill_leaf_ids(action):
return changed
def backfill_missing_leaf_ids(rules):
def backfill_missing_leaf_ids(rules: list[dict[str, Any]]) -> bool:
"""Applique _backfill_leaf_ids à CHAQUE règle de `rules` (mutées EN
PLACE) — renvoie True si au moins une règle a été modifiée, auquel
cas l'appelant DOIT persister `rules` (voir routes/triggers/
@@ -663,7 +673,7 @@ def backfill_missing_leaf_ids(rules):
return changed
def resolve_collision_rules(obj):
def resolve_collision_rules(obj: dict[str, Any]) -> list[dict[str, Any]]:
"""Décode attributes._collision_rules en liste Python, jamais None —
un attribut absent/corrompu retombe sur aucune règle (comportement
inchangé pour tout objet déjà posé avant l'ajout de cet éditeur)."""
+9 -4
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
# Boîte de collision d'un objet de scène (voir screens/scenes/
# set_scene_object_collision.py) : posée AUTOMATIQUEMENT sur chaque objet
@@ -9,14 +10,18 @@ import json
# forme (rectangle/cercle) et taille/décalage propres, pour un sprite très
# paddé (ex. les frames CraftPix) dont la silhouette réelle est bien plus
# petite que son canevas.
DEFAULT_COLLISION_SETTINGS = {
"enabled": True, "shape": "rectangle", "width": None, "height": None,
"offset_x": 0, "offset_y": 0,
DEFAULT_COLLISION_SETTINGS: dict[str, Any] = {
"enabled": True,
"shape": "rectangle",
"width": None,
"height": None,
"offset_x": 0,
"offset_y": 0,
}
COLLISION_SHAPES = ["rectangle", "cercle"]
def resolve_collision_settings(obj):
def resolve_collision_settings(obj: dict[str, Any]) -> dict[str, Any]:
"""Décode attributes._collision en dict Python, jamais None — un
attribut absent/corrompu retombe sur le défaut (boîte de collision =
la boîte entière de l'objet, comme si ce réglage n'existait pas)."""
+8 -4
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
# Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface",
# templates/scene_edit.html, screens/scenes/add_scene_object.py kind=
@@ -7,7 +8,7 @@ import json
# static/js/play/dialogue-box-controller.js) — header (nom du
# personnage qui parle), corps (texte), pied (bouton "Suivant"),
# entièrement stylisable par l'auteur.
DEFAULT_DIALOGUE_BOX_STYLE = {
DEFAULT_DIALOGUE_BOX_STYLE: dict[str, Any] = {
"font_family": "inherit",
"font_size": 14,
"font_weight": "normal",
@@ -20,14 +21,17 @@ DEFAULT_DIALOGUE_BOX_STYLE = {
FONT_WEIGHT_CHOICES = ["normal", "bold"]
def sanitize_dialogue_box_style(raw_style):
def sanitize_dialogue_box_style(raw_style: Any) -> dict[str, Any]:
style = dict(DEFAULT_DIALOGUE_BOX_STYLE)
if not isinstance(raw_style, dict):
return style
if isinstance(raw_style.get("font_family"), str) and raw_style["font_family"].strip():
style["font_family"] = raw_style["font_family"].strip()[:60]
raw_font_size = raw_style.get("font_size")
try:
style["font_size"] = max(8, min(48, int(raw_style.get("font_size"))))
if raw_font_size is None:
raise ValueError
style["font_size"] = max(8, min(48, int(raw_font_size)))
except (TypeError, ValueError):
pass
if raw_style.get("font_weight") in FONT_WEIGHT_CHOICES:
@@ -39,7 +43,7 @@ def sanitize_dialogue_box_style(raw_style):
return style
def resolve_dialogue_box_style(obj):
def resolve_dialogue_box_style(obj: dict[str, Any]) -> dict[str, Any]:
raw = (obj.get("attributes") or {}).get("_dialogue_box_style")
if not raw:
return dict(DEFAULT_DIALOGUE_BOX_STYLE)
@@ -1,16 +1,16 @@
from ..screens_repo.list_screens import list_screens
from ..scenes.list_scene_objects import list_scene_objects
from ..screens_repo.list_screens import list_screens
from .personnage_data import resolve_personnage_data
def list_used_forge_characters(slug):
def list_used_forge_characters(slug: str) -> set[str]:
"""Ensemble des slugs SPRITE_LIBRARY (screens.SPRITE_LIBRARY)
réellement référencés par CE jeu — tous écrans confondus. Utilisé par
publish/build_scorm_package.py pour ne copier dans le .zip exporté que les
dossiers static/characters/animals/<animal>/<NN>/ réellement utilisés
par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque
(voir le plan "Bibliothèque de sprites animaux CraftPix")."""
used = set()
used: set[str] = set()
for s in list_screens(slug, include_templates=True):
for o in list_scene_objects(slug, s["id"]):
if o.get("kind") == "personnage":
+13 -6
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
# ZQSD (convention AZERTY, comme le reste de l'éditeur) + E pour interagir
# — un objet de scène kind="personnage" se déplace et s'anime TOUT SEUL
@@ -11,9 +12,15 @@ import json
# "extra" : touches SUPPLÉMENTAIRES, chacune liée à une pose du personnage
# au choix (ex. "sauter" -> "jump") — au-delà des 4 touches de déplacement
# et de "interagir", qui restent les deux seules touches à usage FIXE.
DEFAULT_PERSONNAGE_COMMANDS = {
"haut": "z", "bas": "s", "gauche": "q", "droite": "d",
"interagir": "e", "axe": "libre", "vitesse": 4, "extra": [],
DEFAULT_PERSONNAGE_COMMANDS: dict[str, Any] = {
"haut": "z",
"bas": "s",
"gauche": "q",
"droite": "d",
"interagir": "e",
"axe": "libre",
"vitesse": 4,
"extra": [],
}
# Nombre maximum de touches supplémentaires — large marge pour un vrai
@@ -22,10 +29,10 @@ DEFAULT_PERSONNAGE_COMMANDS = {
_MAX_EXTRA_COMMANDS = 20
def sanitize_extra_commands(raw_extra):
def sanitize_extra_commands(raw_extra: Any) -> list[dict[str, str]]:
if not isinstance(raw_extra, list):
return []
cleaned = []
cleaned: list[dict[str, str]] = []
for item in raw_extra[:_MAX_EXTRA_COMMANDS]:
if not isinstance(item, dict):
continue
@@ -36,7 +43,7 @@ def sanitize_extra_commands(raw_extra):
return cleaned
def resolve_personnage_commands(el):
def resolve_personnage_commands(el: dict[str, Any]) -> dict[str, Any]:
"""Décode attributes._personnage_commandes (voir
screens/scenes/set_scene_object_commands.py) en dict Python, jamais
None — un attribut absent/corrompu retombe sur les touches par défaut
+6 -5
View File
@@ -1,7 +1,8 @@
import json
from typing import Any
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
from ..labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
# Résolution RUNTIME (jeu jouable) : le catalogue COMPLET, quel que soit le
# rôle du créateur — voir le plan "Bibliothèque de sprites animaux
@@ -13,7 +14,7 @@ _SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY}
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
def resolve_personnage_data(el):
def resolve_personnage_data(el: dict[str, Any]) -> dict[str, Any]:
"""Décode attributes._personnage_data (voir set_personnage_data.py) en
dict Python, jamais None — un attribut absent/corrompu retombe sur le
défaut posé à la création (default_style_for_widget.py)."""
@@ -27,18 +28,18 @@ def resolve_personnage_data(el):
return data if isinstance(data, dict) else dict(_DEFAULT_PERSONNAGE_DATA)
def resolve_personnage_animations(el):
def resolve_personnage_animations(el: dict[str, Any]) -> dict[str, list[str]]:
"""{nom_animation: [urls...]} pour cet élément personnage, quelle que
soit sa source — Forge (SPRITE_LIBRARY) ou sprites propres au créateur
(ses propres frames importées, voir personnage-panel.js)."""
data = resolve_personnage_data(el)
if data.get("source") == "custom":
return data.get("animations") or {}
character = _SPRITE_LIBRARY.get(data.get("forge_character"))
character = _SPRITE_LIBRARY.get(data.get("forge_character") or "")
return character["animations"] if character else {}
def resolve_personnage_idle_frame(el):
def resolve_personnage_idle_frame(el: dict[str, Any]) -> str:
"""Première image de l'animation "idle" (ou, à défaut, de la première
animation disponible) — utilisée pour le rendu serveur ET l'aperçu
animé de l'éditeur, jamais vide tant qu'au moins une frame existe."""
+3 -1
View File
@@ -1,3 +1,5 @@
from typing import Any
# Rôle d'un personnage posé sur une scène 2D (voir
# screens/scenes/set_scene_object_role.py) : seul un personnage "joueur"
# est déplacé/animé automatiquement au clavier et suivi par la caméra
@@ -11,7 +13,7 @@ PERSONNAGE_ROLE_LABELS = {"joueur": "Joueur", "ennemie": "Ennemi", "pnj": "PNJ"}
DEFAULT_PERSONNAGE_ROLE = "joueur"
def resolve_personnage_role(el):
def resolve_personnage_role(el: dict[str, Any]) -> str:
"""Décode attributes._personnage_role — un attribut absent/corrompu
retombe sur "joueur" (comportement inchangé pour tout personnage posé
avant l'ajout de ce réglage : c'était déjà, de fait, le seul rôle qui
+8 -4
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
# Réglages "❓ Boîte à quiz" propres au quiz AUTONOME/plein écran (demande
# explicite : "étendre les fonctionnalités de la boîte à quiz pour les RH
@@ -19,7 +20,7 @@ import json
# Les deux réglages sont conservés INDÉPENDAMMENT (jamais écrasés l'un par
# l'autre) : activer/désactiver le plein écran change juste lequel des
# deux s'applique, sans jamais perdre le choix de l'autre.
DEFAULT_QUIZ_BOX_CONFIG = {
DEFAULT_QUIZ_BOX_CONFIG: dict[str, Any] = {
"fullscreen": False,
"timer_mode": "aucun",
"timer_seconds": 20,
@@ -68,15 +69,18 @@ _MIN_TIMER_SECONDS = 3
_MAX_TIMER_SECONDS = 600
def sanitize_quiz_box_config(raw_config):
def sanitize_quiz_box_config(raw_config: Any) -> dict[str, Any]:
config = dict(DEFAULT_QUIZ_BOX_CONFIG)
if not isinstance(raw_config, dict):
return config
config["fullscreen"] = bool(raw_config.get("fullscreen"))
if raw_config.get("timer_mode") in TIMER_MODES:
config["timer_mode"] = raw_config["timer_mode"]
raw_timer_seconds = raw_config.get("timer_seconds")
try:
config["timer_seconds"] = max(_MIN_TIMER_SECONDS, min(_MAX_TIMER_SECONDS, int(raw_config.get("timer_seconds"))))
if raw_timer_seconds is None:
raise ValueError
config["timer_seconds"] = max(_MIN_TIMER_SECONDS, min(_MAX_TIMER_SECONDS, int(raw_timer_seconds)))
except (TypeError, ValueError):
pass
if raw_config.get("dialog_template") in QUIZ_BOX_DIALOG_TEMPLATES:
@@ -86,7 +90,7 @@ def sanitize_quiz_box_config(raw_config):
return config
def resolve_quiz_box_config(obj):
def resolve_quiz_box_config(obj: dict[str, Any]) -> dict[str, Any]:
raw = (obj.get("attributes") or {}).get("_quiz_box_config")
if not raw:
return dict(DEFAULT_QUIZ_BOX_CONFIG)
+53 -43
View File
@@ -1,4 +1,5 @@
import html as html_lib
from typing import Any, Callable
# ---------- "❓ Boîte à quiz" — DEUX catégories de modèles bien distinctes
# (voir screens/rendering/quiz_box_config.py) :
@@ -43,7 +44,7 @@ import html as html_lib
# render_quiz_box_defaut.
def _quiz_data_attrs(obj, config, active_template):
def _quiz_data_attrs(obj: dict[str, Any], config: dict[str, Any], active_template: str) -> str:
return (
f'data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
f'data-quiz-template="{active_template}" '
@@ -52,7 +53,7 @@ def _quiz_data_attrs(obj, config, active_template):
)
def _quiz_box_style(obj, style):
def _quiz_box_style(obj: dict[str, Any], style: dict[str, Any]) -> str:
return (
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; "
@@ -61,16 +62,16 @@ def _quiz_box_style(obj, style):
)
def _quiz_topbar_html():
def _quiz_topbar_html() -> str:
return (
'<div class="quizBoxTopBar" data-quiz-role="topbar">'
'<span class="quizBoxScoreDisplay" data-quiz-role="score">Score : 0</span>'
'<span class="quizBoxTimerDisplay" data-quiz-role="timer"></span>'
'</div>'
"</div>"
)
def _quiz_header_row_html(header_text, header_bg=None):
def _quiz_header_row_html(header_text: str, header_bg: str | None = None) -> str:
# Le score/minuteur (topbar) est un FRÈRE du header, jamais son enfant
# (voir le commentaire d'en-tête) — les deux partagent ici le MÊME
# bandeau (fond commun posé sur ".quizBoxHeaderRow", jamais sur le
@@ -79,29 +80,28 @@ def _quiz_header_row_html(header_text, header_bg=None):
# part au-dessus (demande explicite). `header_bg=None` : aucun fond
# posé, le bandeau reste transparent (repli disponible pour un futur
# modèle qui n'en aurait pas besoin).
bg_style = f' style="background:{html_lib.escape(header_bg)}"' if header_bg else ''
bg_style = f' style="background:{html_lib.escape(header_bg)}"' if header_bg else ""
return (
f'<div class="quizBoxHeaderRow"{bg_style}>'
f'<div class="quizBoxHeader" data-quiz-role="header">{header_text}</div>'
+ _quiz_topbar_html() +
'</div>'
f'<div class="quizBoxHeader" data-quiz-role="header">{header_text}</div>' + _quiz_topbar_html() + "</div>"
)
def render_quiz_box_defaut(obj, config, style):
def render_quiz_box_defaut(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
return (
f'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
+ _quiz_header_row_html("Quête : Titre de la quête", style["header_bg"]) +
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
f'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" '
f'{_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
+ _quiz_header_row_html("Quête : Titre de la quête", style["header_bg"])
+ f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
f'<div class="quizBoxChoices" data-quiz-role="choices">'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
f'</div></div></div>'
f"</div></div></div>"
)
def render_quiz_box_manga_dialogue(obj, config, style):
def render_quiz_box_manga_dialogue(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
# "Manga" (boîte de dialogue) — pendant, en petite carte flottante
# JAMAIS plein écran, du modèle "page de quiz" "Manga" (voir
# render_quiz_box_page_manga), validé par le créateur via une
@@ -123,10 +123,12 @@ def render_quiz_box_manga_dialogue(obj, config, style):
# doublerait dans l'aperçu éditeur (jamais réécrit par le JS avant le
# premier vrai jeu).
return (
f'<div class="quizBoxWidget quizBoxWidget--mangaDialogue" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
f'<div class="quizBoxWidget quizBoxWidget--mangaDialogue" role="dialog" aria-live="polite" '
f'aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} '
f'style="{_quiz_box_style(obj, style)}">'
'<div class="quizMangaDialogueFrame">'
+ _quiz_header_row_html("Titre de la quête", style["header_bg"]) +
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
+ _quiz_header_row_html("Titre de la quête", style["header_bg"])
+ f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
f'<div class="quizBoxChoices" data-quiz-role="choices">'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
@@ -138,8 +140,8 @@ def render_quiz_box_manga_dialogue(obj, config, style):
# demande explicite), donc ce repère est maintenant fidèle au
# comportement réel, pas juste à l'aperçu éditeur.
'<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>'
'</div></div>'
'</div>'
"</div></div>"
"</div>"
# Bandeau de pied de page TOUJOURS visible (comme la maquette
# validée) — purement décoratif (aria-hidden, jamais un
# data-quiz-role), le moteur JS partagé ne le touche JAMAIS : le
@@ -152,11 +154,11 @@ def render_quiz_box_manga_dialogue(obj, config, style):
# l'intérieur au lieu d'être positionnés par-dessus TOUT le
# widget (.quizBoxWidget--mangaDialogue est position:relative).
'<div class="quizMangaDialogueFooterBand" aria-hidden="true"></div>'
'</div>'
"</div>"
)
def render_quiz_box_page_manga(obj, config, style):
def render_quiz_box_page_manga(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
# "Manga" — premier modèle "PAGE DE QUIZ" (voir screens/rendering/
# quiz_box_config.py::QUIZ_BOX_PAGE_TEMPLATES) : thème complet et
# autonome, validé par le créateur via une maquette interactive avant
@@ -185,18 +187,20 @@ def render_quiz_box_page_manga(obj, config, style):
# dédié pour le maintenir à jour question après question.
timer_placeholder = "Aucun" if config["timer_mode"] == "aucun" else ""
return (
f'<div class="quizBoxWidget quizBoxWidget--manga" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} style="{_quiz_box_style(obj, style)}">'
f'<div class="quizBoxWidget quizBoxWidget--manga" role="dialog" aria-live="polite" '
f'aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} '
f'style="{_quiz_box_style(obj, style)}">'
'<div class="mangaTopbar">'
'<div class="mangaPlate mangaPlate--timer">'
'<span class="mangaPlateLabel">Temps</span>'
f'<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer">{timer_placeholder}</span>'
'</div>'
"</div>"
'<div class="mangaTitlemark"><span>Manga Quiz</span></div>'
'<div class="mangaPlate mangaPlate--score">'
'<span class="mangaPlateLabel">Score</span>'
'<span class="mangaPlateValue" data-quiz-role="score">Score : 0</span>'
'</div>'
'</div>'
"</div>"
"</div>"
# Barre d'étapes (voir static/js/play/quiz-box-templates.js::
# forgeQuizMangaOnShow) — remplie EN JEU (nombre de questions total
# connu seulement à l'exécution, jamais à la pose de l'objet) ;
@@ -206,11 +210,11 @@ def render_quiz_box_page_manga(obj, config, style):
'<div class="mangaPanelQuestion">'
'<span class="mangaKicker" data-quiz-role="header">Titre de la quête</span>'
'<p class="mangaQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
'</div>'
"</div>"
'<div class="mangaChoices" data-quiz-role="choices">'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
'</div>'
"</div>"
# Écran de résultat de fin de quiz — voir forgeQuizMangaOnQuizComplete
# (static/js/play/quiz-box-templates.js), même mécanique que
# "classique" (render_quiz_box_page_classique) mais dans le thème
@@ -221,11 +225,11 @@ def render_quiz_box_page_manga(obj, config, style):
# bandeau topbar/score + la barre d'étapes visibles. data-manga-
# role (pas data-quiz-role) : décoration propre à ce modèle.
'<div class="mangaResultZone" data-manga-role="result"></div>'
'</div>'
"</div>"
)
def render_quiz_box_page_classique(obj, config, style):
def render_quiz_box_page_classique(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
# "Classique" — modèle de PAGE DE QUIZ DE BASE (sobre/professionnel),
# validé par le créateur via une maquette interactive (formation
# sécurité incendie comme exemple). Reprend VOLONTAIREMENT les
@@ -249,23 +253,27 @@ def render_quiz_box_page_classique(obj, config, style):
# dans [data-quiz-role="choices"]) se pose en bandeau bas façon
# pied de page, dans la couleur configurée via "🎨 Style"
# (footer_bg), jamais une couleur figée en dur.
f'<div class="quizBoxWidget quizBoxWidget--classique" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} style="{_quiz_box_style(obj, style)} --classique-footer-bg:{html_lib.escape(style["footer_bg"])}; --classique-body-bg:{html_lib.escape(style["body_bg"])};">'
f'<div class="quizBoxWidget quizBoxWidget--classique" role="dialog" aria-live="polite" '
f'aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} '
f'style="{_quiz_box_style(obj, style)} '
f"--classique-footer-bg:{html_lib.escape(style['footer_bg'])}; "
f'--classique-body-bg:{html_lib.escape(style["body_bg"])};">'
f'<div class="classiqueHeaderBar" style="background:{html_lib.escape(style["header_bg"])}">'
'<div class="classiqueHeaderTitleWrap">'
'<span class="classiqueHeaderEyebrow">❓ Quiz</span>'
f'<span class="classiqueHeaderTitle">{title}</span>'
'</div>'
"</div>"
'<div class="classiqueHeaderChips">'
'<div class="classiqueChip classiqueChip--timer">'
'<span class="classiqueChipLabel">Temps</span>'
f'<span class="classiqueChipValue quizBoxTimerDisplay" data-quiz-role="timer">{timer_placeholder}</span>'
'</div>'
"</div>"
'<div class="classiqueChip classiqueChip--score">'
'<span class="classiqueChipLabel">Score</span>'
'<span class="classiqueChipValue" data-quiz-role="score">Score : 0</span>'
'</div>'
'</div>'
'</div>'
"</div>"
"</div>"
"</div>"
f'<div class="classiqueBody" style="background:{html_lib.escape(style["body_bg"])}">'
'<div class="classiqueContent">'
# Barre d'étapes — même principe que "manga" (voir
@@ -282,7 +290,7 @@ def render_quiz_box_page_classique(obj, config, style):
'<div class="classiqueChoices" data-quiz-role="choices">'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
'</div>'
"</div>"
# Encart d'explication après réponse — RÉSERVÉ pour une
# fonctionnalité FUTURE, PAS ENCORE implémentée (demande
# explicite : "tu peut le mettre dans le modele mais ne
@@ -299,8 +307,8 @@ def render_quiz_box_page_classique(obj, config, style):
# CSS tout le reste : progression/question/choix). data-classique-
# role (pas data-quiz-role) : décoration propre à ce modèle.
'<div class="classiqueResultZone" data-classique-role="result"></div>'
'</div>'
'</div>'
"</div>"
"</div>"
# Bandeau de pied de page TOUJOURS visible (footer_bg, même
# esprit que header/body ci-dessus) — purement décoratif
# (aria-hidden, jamais un data-quiz-role), le moteur JS partagé
@@ -309,22 +317,24 @@ def render_quiz_box_page_classique(obj, config, style):
# (voir static/style.css), pas DANS ce bandeau — demande
# explicite : "il manque le footer foncé avec le bouton".
'<div class="classiqueFooterBand" aria-hidden="true"></div>'
'</div>'
"</div>"
)
QUIZ_BOX_DIALOG_RENDERERS = {
_QuizRenderer = Callable[[dict[str, Any], dict[str, Any], dict[str, Any]], str]
QUIZ_BOX_DIALOG_RENDERERS: dict[str, _QuizRenderer] = {
"defaut": render_quiz_box_defaut,
"manga_dialogue": render_quiz_box_manga_dialogue,
}
QUIZ_BOX_PAGE_RENDERERS = {
QUIZ_BOX_PAGE_RENDERERS: dict[str, _QuizRenderer] = {
"classique": render_quiz_box_page_classique,
"manga": render_quiz_box_page_manga,
}
def render_quiz_box(obj, config, style):
def render_quiz_box(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
"""Point d'entrée UNIQUE (voir screens/scenes/render_scene_object.py)
— bascule entre les deux catégories de modèles selon `fullscreen`
(demande explicite : ces modèles ne se voient JAMAIS dans l'autre
+3 -3
View File
@@ -1,5 +1,5 @@
from ..screens_repo.list_screens import list_screens
from ..scenes.list_scene_objects import list_scene_objects
from ..screens_repo.list_screens import list_screens
# 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
@@ -8,14 +8,14 @@ from ..scenes.list_scene_objects import list_scene_objects
_EXCLUDED_KINDS = {"dialogue_box"}
def list_named_scene_object_names(slug):
def list_named_scene_object_names(slug: str) -> list[str]:
"""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 : un
déclencheur 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()
names: set[str] = set()
for s in list_screens(slug):
if s["kind"] != "jeu_2d":
continue
+3 -2
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
from .collision_rules import sanitize_collision_rules
@@ -13,7 +14,7 @@ from .collision_rules import sanitize_collision_rules
TRIGGER_TYPES_SCREEN = ["affichage"]
def sanitize_screen_triggers(raw_rules):
def sanitize_screen_triggers(raw_rules: Any) -> list[dict[str, Any]]:
"""Valide/nettoie une liste de règles "déclencheur d'écran -> action"
postée par le client (voir routes/screens/screen_triggers.py) — même
garanties que sanitize_collision_rules, juste un vocabulaire de
@@ -21,7 +22,7 @@ def sanitize_screen_triggers(raw_rules):
return sanitize_collision_rules(raw_rules, allowed_triggers=TRIGGER_TYPES_SCREEN)
def resolve_screen_triggers(screen):
def resolve_screen_triggers(screen: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Décode _screens._screen_triggers en liste Python, jamais None — un
écran créé avant l'ajout de cette fonctionnalité (colonne NULL)
retombe sur aucune règle, même principe que resolve_collision_rules
-4
View File
@@ -1,4 +0,0 @@
def _trigger_for(widget_key, attributes):
if (attributes or {}).get("type") == "submit":
return "soumission"
return "clic"
+38 -31
View File
@@ -1,10 +1,15 @@
def _game_objects_with_thumbnails(slug, screens_mod):
from typing import Any
from .collision_rules import LEAF_ACTION_TYPES
def _game_objects_with_thumbnails(slug: str, screens_mod: Any) -> list[dict[str, Any]]:
"""Objets de TOUTES les scènes "jeu_2d" du jeu (contrairement à
screens.list_scene_objects, qui ne prend qu'un screen_id) —
rendered_html ajouté comme l'exigent screens.build_collision_rule_objects
(voir routes/scenes/scene_edit_view.py pour le même patron, une seule
scène)."""
result = []
result: list[dict[str, Any]] = []
for s in screens_mod.list_screens(slug):
if s["kind"] != "jeu_2d":
continue
@@ -17,14 +22,12 @@ def _game_objects_with_thumbnails(slug, screens_mod):
return result
from .collision_rules import LEAF_ACTION_TYPES
# Champs affichés pour chaque type de feuille (voir screens/rendering/
# collision_rules.py pour la forme exacte de chacune) — un simple filtre
# sur l'action déjà sanitizée, jamais une recopie de valeur : ajouter un
# type de feuille suffit à l'ajouter ici, sans dupliquer sa logique de
# sanitisation.
_LEAF_DISPLAY_FIELDS = {
_LEAF_DISPLAY_FIELDS: dict[str, tuple[str, ...]] = {
"dialogue": ("dialogue",),
"variable": ("id", "target_variable", "data_operation", "data_value"),
"surbrillance": ("id", "object_id", "valeur"),
@@ -36,21 +39,21 @@ _LEAF_DISPLAY_FIELDS = {
}
def _describe_leaf(action):
def _describe_leaf(action: dict[str, Any]) -> dict[str, Any]:
"""Une seule feuille (voir LEAF_ACTION_TYPES, screens/rendering/
collision_rules.py) décrite pour l'affichage, SANS son éventuel
"then" (voir _describe_chain, qui aplatit la chaîne complète)."""
action_type = action.get("type")
fields = _LEAF_DISPLAY_FIELDS.get(action_type, ())
fields = _LEAF_DISPLAY_FIELDS.get(action_type or "", ())
return {"type": action_type, **{f: action[f] for f in fields if f in action}}
def _describe_chain(action):
def _describe_chain(action: Any) -> list[dict[str, Any]]:
"""Aplatit une feuille + ses "then" successifs en une LISTE, dans
l'ordre d'exécution — voir maquette validée ("plusieurs actions
chaînées"). Liste vide si `action` n'est pas une feuille reconnue
(voir LEAF_ACTION_TYPES)."""
chain = []
chain: list[dict[str, Any]] = []
node = action
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
chain.append(_describe_leaf(node))
@@ -58,13 +61,13 @@ def _describe_chain(action):
return chain
def _branch_info(branch):
def _branch_info(branch: Any) -> dict[str, Any] | None:
if not isinstance(branch, dict) or branch.get("type") not in LEAF_ACTION_TYPES:
return None
return {"chain": _describe_chain(branch)}
def _resolve_action_trigger(action, has_interagir=False):
def _resolve_action_trigger(action: Any, has_interagir: bool = False) -> dict[str, Any]:
"""Décrit ENTIÈREMENT un arbre d'action (voir screens/rendering/
collision_rules.py) pour l'affichage dans l'onglet "Déclencheurs" —
chaque feuille affiche DIRECTEMENT son propre contenu, où qu'elle soit
@@ -85,9 +88,11 @@ def _resolve_action_trigger(action, has_interagir=False):
return _resolve_action_trigger(action.get("sub_action") or {}, has_interagir=True)
if action_type == "condition":
return {
"has_interagir": has_interagir, "chain": [],
"has_interagir": has_interagir,
"chain": [],
"condition": {
"variable": action.get("variable"), "operateur": action.get("operateur"),
"variable": action.get("variable"),
"operateur": action.get("operateur"),
"valeur": action.get("valeur"),
"si_vrai": _branch_info(action.get("si_vrai")),
"si_faux": _branch_info(action.get("si_faux")),
@@ -96,7 +101,7 @@ def _resolve_action_trigger(action, has_interagir=False):
return {"has_interagir": has_interagir, "chain": [], "condition": None}
def list_all_triggers(slug):
def list_all_triggers(slug: str) -> list[dict[str, Any]]:
"""TOUS les déclencheurs du jeu, un par occurrence de "À la collision"
posée sur un objet de scène — remplace l'ancienne recherche inversée
par quête : plus de filtrage par id, chaque déclencheur affiche
@@ -107,7 +112,7 @@ def list_all_triggers(slug):
objects = _game_objects_with_thumbnails(slug, screens_mod)
rule_objects = screens_mod.build_collision_rule_objects(objects)
result = []
result: list[dict[str, Any]] = []
for obj in rule_objects:
# Rétro-remplissage des feuilles posées avant que "id" ne devienne
# systématique (ex. par l'IA, ce champ restant optionnel dans son
@@ -120,19 +125,21 @@ def list_all_triggers(slug):
screens_mod.set_scene_object_collision_rules(slug, obj["id"], obj["rules"])
for rule in obj["rules"]:
info = _resolve_action_trigger(rule["action"])
result.append({
"object_id": obj["id"],
"object_name": obj["name"],
"thumbnail_html": obj["thumbnail_html"],
"has_interagir": info["has_interagir"],
"trigger": rule["trigger"],
"condition": info["condition"],
"chain": info["chain"],
})
result.append(
{
"object_id": obj["id"],
"object_name": obj["name"],
"thumbnail_html": obj["thumbnail_html"],
"has_interagir": info["has_interagir"],
"trigger": rule["trigger"],
"condition": info["condition"],
"chain": info["chain"],
}
)
return result
def _collect_dialogue_lines_from_action(action, acc):
def _collect_dialogue_lines_from_action(action: Any, acc: list[list[dict[str, Any]]]) -> None:
if not isinstance(action, dict):
return
action_type = action.get("type")
@@ -150,7 +157,7 @@ def _collect_dialogue_lines_from_action(action, acc):
_collect_dialogue_lines_from_action(action.get("si_faux"), acc)
def collect_all_dialogue_lines(slug):
def collect_all_dialogue_lines(slug: str) -> list[list[dict[str, Any]]]:
"""Toutes les répliques/questions de TOUS les dialogues embarqués dans
les règles de collision de TOUS les objets de scène du jeu (voir
screens/rendering/collision_rules.py::"dialogue".dialogue), y compris
@@ -160,7 +167,7 @@ def collect_all_dialogue_lines(slug):
publish/build_scorm_package.py)."""
import screens as screens_mod
acc = []
acc: list[list[dict[str, Any]]] = []
for s in screens_mod.list_screens(slug):
if s["kind"] != "jeu_2d":
continue
@@ -172,7 +179,7 @@ def collect_all_dialogue_lines(slug):
return acc
def _collect_completable_ids_from_action(action, acc):
def _collect_completable_ids_from_action(action: Any, acc: list[str]) -> None:
if not isinstance(action, dict):
return
action_type = action.get("type")
@@ -190,7 +197,7 @@ def _collect_completable_ids_from_action(action, acc):
_collect_completable_ids_from_action(action.get("si_faux"), acc)
def list_completable_dialogue_ids(slug):
def list_completable_dialogue_ids(slug: str) -> list[str]:
"""Tous les id de dialogue du jeu dont l'action porte
`mark_completed` (voir screens/rendering/collision_rules.py), y
compris au fil d'une chaîne "then" — utilisée par static/js/play/
@@ -199,7 +206,7 @@ def list_completable_dialogue_ids(slug):
aucune notion de quête."""
import screens as screens_mod
acc = []
acc: list[str] = []
for s in screens_mod.list_screens(slug):
if s["kind"] != "jeu_2d":
continue
@@ -211,7 +218,7 @@ def list_completable_dialogue_ids(slug):
return acc
def list_all_collision_objects(slug):
def list_all_collision_objects(slug: str) -> list[dict[str, Any]]:
"""Catalogue de TOUS les objets éligibles à une règle de collision,
toutes scènes "jeu_2d" confondues — pour peupler le sélecteur d'objet
de la modale d'ajout de déclencheur (voir routes/triggers/)."""
+26 -10
View File
@@ -24,7 +24,18 @@ _DECOR_DEFAULT_SIZE = (200, 200)
_DECOR_COLLISION_SIZE = (150, 150)
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None, image_url=None, image_width=None, image_height=None, x=None, y=None):
def add_scene_object(
slug: str,
scene_id: int,
kind: str = "personnage",
forge_character: str | None = None,
background_slug: str | None = None,
image_url: str | None = None,
image_width: int | None = None,
image_height: int | None = None,
x: float | None = None,
y: float | None = None,
) -> int:
"""Pose un nouvel objet sur une scène (voir ensure_scene_schema.py).
kind="personnage" : attributes._personnage_data, MÊME structure/mêmes
fonctions resolve_personnage_* que le widget "personnage" de l'éditeur
@@ -64,8 +75,9 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
if kind == "personnage":
data = dict(_DEFAULT_PERSONNAGE_DATA)
if forge_character:
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
from ..labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
if forge_character in PUBLIC_SPRITE_LIBRARY or forge_character in ADMIN_SPRITE_LIBRARY:
data["forge_character"] = forge_character
attributes["_personnage_data"] = json.dumps(data)
@@ -86,11 +98,15 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
elif kind == "decor" and image_url:
attributes["src"] = image_url
width, height = _DECOR_DEFAULT_SIZE
attributes["_collision"] = json.dumps({
"width": _DECOR_COLLISION_SIZE[0], "height": _DECOR_COLLISION_SIZE[1],
})
attributes["_collision"] = json.dumps(
{
"width": _DECOR_COLLISION_SIZE[0],
"height": _DECOR_COLLISION_SIZE[1],
}
)
elif kind == "fond" and background_slug:
from ..labels.background_library import BACKGROUND_LIBRARY
background = BACKGROUND_LIBRARY.get(background_slug)
if background:
attributes["src"] = background["url"]
@@ -104,9 +120,9 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
if kind == "fond":
z_index = -1000
else:
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _scene_objects WHERE scene_id = ?", (scene_id,)
).fetchone()["m"]
max_z = conn.execute("SELECT MAX(z_index) AS m FROM _scene_objects WHERE scene_id = ?", (scene_id,)).fetchone()[
"m"
]
z_index = (max_z or 0) + 1
cols = ["scene_id", "kind", "attributes", "z_index"]
values = [scene_id, kind, json.dumps(attributes), z_index]
@@ -123,8 +139,8 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
cols += ["x", "y"]
values += [x, y]
placeholders = ", ".join(["?"] * len(values))
conn.execute(f"INSERT INTO _scene_objects ({', '.join(cols)}) VALUES ({placeholders})", values)
object_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.execute(f"INSERT INTO _scene_objects ({', '.join(cols)}) VALUES ({placeholders})", values) # nosec B608 # noqa: S608 - cols construit uniquement a partir de litteraux Python, jamais d'une cle arbitraire ; valeurs parametrees (?)
object_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return object_id
+5 -3
View File
@@ -1,10 +1,12 @@
from typing import Any
from ..rendering.collision_rules import resolve_collision_rules, sanitize_collision_rules, set_then_action_in_rules
from .get_scene_object import get_scene_object
from .set_scene_object_collision_rules import set_scene_object_collision_rules
from ..rendering.collision_rules import resolve_collision_rules, sanitize_collision_rules, set_then_action_in_rules
def append_action_to_trigger(slug, object_id, after_id, raw_action):
""""+ Ajouter une action" — extrait de routes/triggers/triggers.py::
def append_action_to_trigger(slug: str, object_id: int, after_id: str, raw_action: Any) -> bool:
""" "+ Ajouter une action" — extrait de routes/triggers/triggers.py::
trigger_append_action pour être l'UNIQUE chemin d'écriture, partagé
par la route HTTP ET par l'agent IA (voir ai/tools.py) : ni l'un ni
l'autre n'écrit `_collision_rules` directement, tous deux passent par
+16 -13
View File
@@ -1,10 +1,12 @@
from typing import Any
import screens
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
_COLLISION_EXCLUDED_KINDS = ("fond", "dialogue_box", "quiz_box", "score_widget")
def build_collision_rule_objects(objects):
def build_collision_rule_objects(objects: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Une carte "🧩 Collision" (voir templates/scene_edit.html) par objet
ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
@@ -33,14 +35,13 @@ def build_collision_rule_objects(objects):
rôle explicite sont traités comme "pnj" ici, sans rien réécrire en
base."""
implicit_joueur_claimed = any(
(o.get("attributes") or {}).get("_personnage_role") == "joueur"
for o in objects if o["kind"] == "personnage"
(o.get("attributes") or {}).get("_personnage_role") == "joueur" for o in objects if o["kind"] == "personnage"
)
result = []
result: list[dict[str, Any]] = []
for o in objects:
if o["kind"] in _COLLISION_EXCLUDED_KINDS:
continue
role = None
role: str | None = None
if o["kind"] == "personnage":
explicit_role = (o.get("attributes") or {}).get("_personnage_role")
if explicit_role in screens.PERSONNAGE_ROLES:
@@ -52,12 +53,14 @@ def build_collision_rule_objects(objects):
role = "pnj"
if role == "joueur":
continue
result.append({
"id": o["id"],
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
"kind": o["kind"],
"role": role,
"thumbnail_html": o["rendered_html"],
"rules": screens.resolve_collision_rules(o),
})
result.append(
{
"id": o["id"],
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
"kind": o["kind"],
"role": role,
"thumbnail_html": o["rendered_html"],
"rules": screens.resolve_collision_rules(o),
}
)
return result
+2 -2
View File
@@ -1,11 +1,11 @@
import db
from .ensure_scene_schema import ensure_scene_schema
from ..animations.ensure_animation_schema import ensure_animation_schema
from ..flow.ensure_flow_schema import ensure_flow_schema
from .ensure_scene_schema import ensure_scene_schema
def delete_scene_object(slug, object_id):
def delete_scene_object(slug: str, object_id: int) -> None:
"""Supprime un objet de scène ET les nœuds de logique qui le
référencent (trigger_object_id/target_object_id/cond_element_a/
cond_element_b — aucun n'a de contrainte FK, voir
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def ensure_scene_schema(slug):
def ensure_scene_schema(slug: str) -> None:
"""Crée _scene_objects si nécessaire — le contenu d'une scène pour un
jeu "jeu_2d" (voir db/games/get_game_type.py), équivalent de
_screen_elements pour un jeu "document" mais en coordonnées PIXEL
+2 -1
View File
@@ -1,11 +1,12 @@
import json
from typing import Any
import db
from .ensure_scene_schema import ensure_scene_schema
def get_scene_object(slug, object_id):
def get_scene_object(slug: str, object_id: int) -> dict[str, Any] | None:
ensure_scene_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _scene_objects WHERE id = ?", (object_id,)).fetchone()
+4 -5
View File
@@ -1,18 +1,17 @@
import json
from typing import Any
import db
from .ensure_scene_schema import ensure_scene_schema
def list_scene_objects(slug, scene_id):
def list_scene_objects(slug: str, scene_id: int) -> list[dict[str, Any]]:
ensure_scene_schema(slug)
conn = db.connect(slug)
rows = conn.execute(
"SELECT * FROM _scene_objects WHERE scene_id = ? ORDER BY z_index, id", (scene_id,)
).fetchall()
rows = conn.execute("SELECT * FROM _scene_objects WHERE scene_id = ? ORDER BY z_index, id", (scene_id,)).fetchall()
conn.close()
result = []
result: list[dict[str, Any]] = []
for r in rows:
d = dict(r)
d["attributes"] = json.loads(d["attributes"] or "{}")
+2 -2
View File
@@ -1,9 +1,9 @@
from ..rendering.collision_rules import move_action_in_rules, resolve_collision_rules, sanitize_collision_rules
from .get_scene_object import get_scene_object
from .set_scene_object_collision_rules import set_scene_object_collision_rules
from ..rendering.collision_rules import resolve_collision_rules, sanitize_collision_rules, move_action_in_rules
def move_action_in_trigger(slug, object_id, leaf_id, direction):
def move_action_in_trigger(slug: str, object_id: int, leaf_id: str, direction: str) -> bool:
"""Déplace une action précise d'un cran ("up"/"down", voir
screens/rendering/collision_rules.py::move_action_in_rules) dans son
déclencheur d'OBJET — même patron qu'append_action_to_trigger."""
+2 -2
View File
@@ -1,9 +1,9 @@
from ..rendering.collision_rules import remove_action_from_rules, resolve_collision_rules, sanitize_collision_rules
from .get_scene_object import get_scene_object
from .set_scene_object_collision_rules import set_scene_object_collision_rules
from ..rendering.collision_rules import resolve_collision_rules, sanitize_collision_rules, remove_action_from_rules
def remove_action_from_trigger(slug, object_id, leaf_id):
def remove_action_from_trigger(slug: str, object_id: int, leaf_id: str) -> bool:
"""Retire une action précise (voir screens/rendering/collision_rules.py
::remove_action_from_rules) d'un déclencheur d'OBJET — même patron
qu'append_action_to_trigger : UNIQUE chemin d'écriture, partagé par la
+32 -22
View File
@@ -1,12 +1,13 @@
import html as html_lib
from typing import Any
from ..rendering.personnage_data import resolve_personnage_idle_frame
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
from ..rendering.personnage_data import resolve_personnage_idle_frame
from ..rendering.quiz_box_config import resolve_quiz_box_config
from ..rendering.quiz_box_templates import render_quiz_box
def render_scene_object(obj):
def render_scene_object(obj: dict[str, Any]) -> str:
"""HTML d'un objet de scène — positionnement en PIXELS ABSOLUS (pas de
% fluide, voir ensure_scene_schema.py), mirror volontairement plus
simple que render_element_html.py (pas d'arbre de nesting, pas de
@@ -52,12 +53,12 @@ def render_scene_object(obj):
# collision "dialogue" ait un dialogue à afficher. Toujours VISIBLE
# dans l'éditeur (avec un texte d'exemple) pour pouvoir le
# positionner/styliser.
style = resolve_dialogue_box_style(obj)
box_theme = resolve_dialogue_box_style(obj)
box_style = (
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; "
f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; "
f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};"
f"font-family:{html_lib.escape(box_theme['font_family'])}; font-size:{box_theme['font_size']}px; "
f"font-weight:{box_theme['font_weight']}; color:{html_lib.escape(box_theme['text_color'])};"
)
return (
# role="dialog"/aria-live (RGAA/WCAG 4.1.3) : le texte de
@@ -65,12 +66,17 @@ def render_scene_object(obj):
# static/js/play/dialogue-box-controller.js) sans jamais
# recharger la page — sans annonce explicite, un lecteur
# d'écran ne détecte aucun changement.
f'<div class="dialogueBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
f'<div class="dialogueBoxHeader" data-dialogue-role="header" style="background:{html_lib.escape(style["header_bg"])}">Nom du personnage</div>'
f'<div class="dialogueBoxBody" data-dialogue-role="body" style="background:{html_lib.escape(style["body_bg"])}">Le texte du dialogue s\'affiche ici.</div>'
f'<div class="dialogueBoxFooter" data-dialogue-role="footer" style="background:{html_lib.escape(style["footer_bg"])}">'
f'<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance({obj["id"]})">Suivant →</button>'
f'</div></div>'
f'<div class="dialogueBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" '
f'data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
f'<div class="dialogueBoxHeader" data-dialogue-role="header" '
f'style="background:{html_lib.escape(box_theme["header_bg"])}">Nom du personnage</div>'
f'<div class="dialogueBoxBody" data-dialogue-role="body" '
f'style="background:{html_lib.escape(box_theme["body_bg"])}">Le texte du dialogue s\'affiche ici.</div>'
f'<div class="dialogueBoxFooter" data-dialogue-role="footer" '
f'style="background:{html_lib.escape(box_theme["footer_bg"])}">'
f'<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" '
f'onclick="forgeDialogueBoxAdvance({obj["id"]})">Suivant →</button>'
f"</div></div>"
)
if obj["kind"] == "quiz_box":
# Widget "❓ Boîte à quiz" (voir "🖥️ Interface") — réutilise le
@@ -85,9 +91,9 @@ def render_scene_object(obj):
# respectent le même contrat de data-quiz-role, lu EN JEU par
# static/js/play/dialogue-box-controller.js. "defaut" (Classique)
# reste pixel-identique au rendu d'avant cette extension.
style = resolve_dialogue_box_style(obj)
box_theme = resolve_dialogue_box_style(obj)
config = resolve_quiz_box_config(obj)
return render_quiz_box(obj, config, style)
return render_quiz_box(obj, config, box_theme)
if obj["kind"] == "score_widget":
# Widget "🏆 Score" (voir "🖥️ Interface") — toujours VISIBLE une
# fois posé (pas de collision/déclencheur, contrairement aux deux
@@ -95,18 +101,18 @@ def render_scene_object(obj):
# cours (voir static/js/play/dialogue-box-controller.js::
# forgeUpdateAllScoreWidgets). Même style que les deux autres
# widgets, header_bg sert de fond unique (pas de zone body/footer).
style = resolve_dialogue_box_style(obj)
box_theme = resolve_dialogue_box_style(obj)
box_style = (
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; "
f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; "
f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])}; "
f"background:{html_lib.escape(style['header_bg'])};"
f"font-family:{html_lib.escape(box_theme['font_family'])}; font-size:{box_theme['font_size']}px; "
f"font-weight:{box_theme['font_weight']}; color:{html_lib.escape(box_theme['text_color'])}; "
f"background:{html_lib.escape(box_theme['header_bg'])};"
)
return (
f'<div class="scoreWidget" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
f'🏆 <span data-score-role="value">0</span>'
f'</div>'
f"</div>"
)
if obj["kind"] == "personnage":
src = resolve_personnage_idle_frame(obj)
@@ -117,8 +123,10 @@ def render_scene_object(obj):
# un dialogue de quête réutilise le même nom). alt="" (décoratif)
# sinon, jamais le nom de fichier brut par défaut du navigateur.
alt_attr = f' alt="{html_lib.escape(obj.get("name") or "")}"'
return (f'<img class="sceneObjectSprite" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
f'data-anim-target="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}{alt_attr}>')
return (
f'<img class="sceneObjectSprite" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
f'data-anim-target="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}{alt_attr}>'
)
src = (obj.get("attributes") or {}).get("src", "")
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
# "fond" (voir add_scene_object.py) : même image fixe que "decor",
@@ -132,5 +140,7 @@ def render_scene_object(obj):
# information à transmettre) — un "decor" nommé par l'auteur utilise
# ce nom, sinon alt="" plutôt qu'aucun attribut du tout.
alt_attr = ' alt=""' if obj["kind"] == "fond" else f' alt="{html_lib.escape(obj.get("name") or "")}"'
return (f'<img class="{css_class}" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
f'data-anim-target="{obj["id"]}" style="{style}"{src_attr}{alt_attr}>')
return (
f'<img class="{css_class}" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
f'data-anim-target="{obj["id"]}" style="{style}"{src_attr}{alt_attr}>'
)
+4 -1
View File
@@ -1,4 +1,7 @@
def resolve_scene_world_size(screen, objects):
from typing import Any
def resolve_scene_world_size(screen: dict[str, Any], objects: list[dict[str, Any]]) -> tuple[int, int]:
"""Taille du "monde" d'une scène 2D (voir static/js/play/personnage-
controller.js::forgeUpdateSceneWorldSize, même principe côté serveur) :
un "fond" posé à sa taille RÉELLE (souvent plus grande que la scène/
+2 -1
View File
@@ -1,11 +1,12 @@
import json
from typing import Any
import db
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_collision(slug, object_id, settings):
def set_scene_object_collision(slug: str, object_id: int, settings: dict[str, Any]) -> None:
"""Enregistre la boîte de collision (activée/désactivée, forme,
taille, décalage — voir screens/rendering/collision_settings.py) d'un
objet de scène QUELCONQUE (pas seulement "personnage" : un décor/fond
@@ -1,11 +1,12 @@
import json
from typing import Any
import db
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_collision_rules(slug, object_id, rules):
def set_scene_object_collision_rules(slug: str, object_id: int, rules: list[dict[str, Any]]) -> None:
"""Enregistre la liste de règles "déclencheur -> action" d'un objet de
scène (voir screens/rendering/collision_rules.py, "🧩 Collision" dans
templates/scene_edit.html) — un attribut JSON distinct de tous les
+2 -1
View File
@@ -1,11 +1,12 @@
import json
from typing import Any
import db
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_commands(slug, object_id, commands):
def set_scene_object_commands(slug: str, object_id: int, commands: dict[str, Any]) -> None:
"""Enregistre les touches de déplacement/interaction et le blocage
d'axe d'un objet de scène kind="personnage" (voir
screens/rendering/personnage_commands.py pour les valeurs par défaut,
@@ -1,4 +1,5 @@
import json
from typing import Any
import db
@@ -6,7 +7,7 @@ from ..rendering.dialogue_box_style import sanitize_dialogue_box_style
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_dialogue_box_style(slug, object_id, raw_style):
def set_scene_object_dialogue_box_style(slug: str, object_id: int, raw_style: Any) -> dict[str, Any] | None:
ensure_scene_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone()
+1 -1
View File
@@ -3,7 +3,7 @@ import db
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_name(slug, object_id, name):
def set_scene_object_name(slug: str, object_id: int, name: str | None) -> None:
"""Enregistre le nom d'un objet de scène (colonne `name`, voir
ensure_scene_schema.py — déjà utilisée comme LIBELLÉ d'affichage
partout ailleurs, mais jamais éditable avant le panneau "ℹ️
@@ -1,11 +1,12 @@
import json
from typing import Any
import db
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_personnage_data(slug, object_id, data):
def set_scene_object_personnage_data(slug: str, object_id: int, data: dict[str, Any]) -> None:
"""Change le personnage Forge d'un objet de scène kind="personnage",
ou met à jour ses animations — mirror exact de
screens/elements/set_personnage_data.py, même clé _personnage_data
@@ -1,4 +1,5 @@
import json
from typing import Any
import db
@@ -6,7 +7,7 @@ from ..rendering.quiz_box_config import sanitize_quiz_box_config
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_quiz_config(slug, object_id, raw_config):
def set_scene_object_quiz_config(slug: str, object_id: int, raw_config: Any) -> dict[str, Any] | None:
ensure_scene_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone()
+1 -1
View File
@@ -5,7 +5,7 @@ import db
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_role(slug, object_id, role):
def set_scene_object_role(slug: str, object_id: int, role: str) -> None:
"""Enregistre le rôle (joueur/ennemie/pnj, voir
screens/rendering/personnage_role.py) d'un objet de scène
kind="personnage" — un attribut DISTINCT de _personnage_data (choix du
@@ -3,7 +3,7 @@ import db
from .ensure_scene_schema import ensure_scene_schema
def update_scene_object_geometry(slug, object_id, x, y, width, height):
def update_scene_object_geometry(slug: str, object_id: int, x: float, y: float, width: float, height: float) -> None:
"""Position/taille en PIXELS (pas des %, voir ensure_scene_schema.py)
— utilisé par le glisser-déposer/redimensionnement de l'éditeur de
scène, mirror exact de update_element_geometry.py (éditeur document) :
@@ -1,10 +1,12 @@
from typing import Any
from ..rendering.collision_rules import set_then_action_in_rules
from ..rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers
from .get_screen import get_screen
from .set_screen_triggers import set_screen_triggers
from ..rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers
from ..rendering.collision_rules import set_then_action_in_rules
def append_action_to_screen_trigger(slug, screen_id, after_id, raw_action):
def append_action_to_screen_trigger(slug: str, screen_id: int, after_id: str, raw_action: Any) -> bool:
"""Équivalent de screens.append_action_to_trigger (voir scenes/
append_action_to_trigger.py) pour un déclencheur D'ÉCRAN plutôt que
d'un objet — même chemin d'écriture UNIQUE partagé par la route HTTP
+2 -2
View File
@@ -3,7 +3,7 @@ import db
from .ensure_schema import ensure_schema
def create_screen(slug, name, is_template=False, kind="jeu_2d"):
def create_screen(slug: str, name: str, is_template: bool = False, kind: str = "jeu_2d") -> int:
ensure_schema(slug)
conn = db.connect(slug)
if is_template:
@@ -15,7 +15,7 @@ def create_screen(slug, name, is_template=False, kind="jeu_2d"):
"INSERT INTO _screens (name, order_index, is_template, kind) VALUES (?, ?, ?, ?)",
(name, order_index, 1 if is_template else 0, kind),
)
screen_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
screen_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return screen_id
+5 -1
View File
@@ -1,7 +1,11 @@
import db
def delete_screen(slug, screen_id):
def delete_screen(slug: str, screen_id: int) -> None:
# Les conversations "Générer avec l'IA" de cet écran (et leurs messages)
# sont déjà nettoyées automatiquement par SQLite : _ia_conversations.
# screen_id REFERENCES _screens(id) ON DELETE CASCADE (voir
# screens/ia/ensure_ia_chat_schema.py) — rien à faire ici explicitement.
conn = db.connect(slug)
conn.execute("DELETE FROM _screens WHERE id = ?", (screen_id,))
conn.commit()
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def ensure_schema(slug):
def ensure_schema(slug: str) -> None:
"""Crée les tables de ce module si elles n'existent pas encore — les
jeux créés avant l'ajout de cette fonctionnalité n'ont pas besoin d'être
migrés manuellement, la première visite d'un écran de ce jeu suffit."""
+3 -1
View File
@@ -1,7 +1,9 @@
from typing import Any
import db
def get_screen(slug, screen_id):
def get_screen(slug: str, screen_id: int) -> dict[str, Any] | None:
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _screens WHERE id = ?", (screen_id,)).fetchone()
conn.close()
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from .ensure_schema import ensure_schema
def list_screens(slug, include_templates=False):
def list_screens(slug: str, include_templates: bool = False) -> list[dict[str, Any]]:
"""Par défaut, n'affiche que les vrais écrans du jeu (pas les
écrans-modèles qui hébergent le contenu des éléments de jeu réutilisables
— voir ensure_schema) : c'est ce qu'on veut partout où on énumère "les
@@ -1,10 +1,10 @@
from ..rendering.collision_rules import move_action_in_rules
from ..rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers
from .get_screen import get_screen
from .set_screen_triggers import set_screen_triggers
from ..rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers
from ..rendering.collision_rules import move_action_in_rules
def move_action_in_screen_trigger(slug, screen_id, leaf_id, direction):
def move_action_in_screen_trigger(slug: str, screen_id: int, leaf_id: str, direction: str) -> bool:
"""Équivalent de screens.move_action_in_trigger pour un déclencheur
D'ÉCRAN plutôt que d'un objet."""
screen = get_screen(slug, screen_id)
+1 -1
View File
@@ -3,7 +3,7 @@ import db
from .list_screens import list_screens
def move_screen(slug, screen_id, direction):
def move_screen(slug: str, screen_id: int, direction: int) -> None:
"""direction: -1 (monter, l'écran devient plus tôt dans l'enchaînement)
ou +1 (descendre) — échange l'order_index avec l'écran voisin."""
screens_ = list_screens(slug)
@@ -1,10 +1,10 @@
from ..rendering.collision_rules import remove_action_from_rules
from ..rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers
from .get_screen import get_screen
from .set_screen_triggers import set_screen_triggers
from ..rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers
from ..rendering.collision_rules import remove_action_from_rules
def remove_action_from_screen_trigger(slug, screen_id, leaf_id):
def remove_action_from_screen_trigger(slug: str, screen_id: int, leaf_id: str) -> bool:
"""Équivalent de screens.remove_action_from_trigger pour un
déclencheur D'ÉCRAN plutôt que d'un objet — même chemin d'écriture
UNIQUE partagé par la route HTTP et l'agent IA."""
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def rename_screen(slug, screen_id, new_name):
def rename_screen(slug: str, screen_id: int, new_name: str) -> None:
conn = db.connect(slug)
conn.execute("UPDATE _screens SET name = ? WHERE id = ?", (new_name, screen_id))
conn.commit()
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
def screens_referencing(slug, screen_id):
def screens_referencing(slug: str, screen_id: int) -> list[dict[str, Any]]:
"""Écrans dont un nœud de logique (action "aller_a") cible précisément
cet écran — pour avertir avant suppression plutôt que de casser
silencieusement le lien."""
+1 -1
View File
@@ -1,7 +1,7 @@
import db
def set_scene_size(slug, screen_id, width, height):
def set_scene_size(slug: str, screen_id: int, width: int, height: int) -> None:
"""Redimensionne la scène/caméra elle-même (colonnes scene_width/
scene_height, voir ensure_schema.py) — jusqu'ici fixées à la création
et jamais modifiables ensuite. Utilisé par "📐 Agrandir la caméra à la
@@ -3,7 +3,7 @@ import db
from .ensure_schema import ensure_schema
def set_screen_background_music(slug, screen_id, url):
def set_screen_background_music(slug: str, screen_id: int, url: str | None) -> None:
"""Mémorise l'URL de musique de fond de cet écran (voir le commentaire
sur la colonne background_music_url dans ensure_schema.py). Chaîne vide
-> NULL (pas de musique), comme un simple champ texte qu'on vide."""
+2 -1
View File
@@ -1,11 +1,12 @@
import json
from typing import Any
import db
from .ensure_schema import ensure_schema
def set_screen_triggers(slug, screen_id, rules):
def set_screen_triggers(slug: str, screen_id: int, rules: list[dict[str, Any]]) -> None:
"""Remplace TOUTES les règles "déclencheur d'écran -> action" de cet
écran (voir screens/rendering/screen_triggers.py) — même patron que
set_scene_object_collision_rules pour un objet, mais stocké