diff --git a/.env.example b/.env.example index 398b04c1..17e694fb 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,13 @@ -# Ce fichier ne configure PAS l'application (elle ne lit aucune variable -# d'environnement) — il configure uniquement docker-compose.prod.yml sur le -# serveur de production. Copier en ".env" à côté de docker-compose.prod.yml -# sur le serveur et adapter les valeurs ; ne jamais committer ce ".env". +# Deux usages distincts pour ce fichier, jamais mélangés : +# - En LOCAL (poste de dev) : copier en ".env" à la racine du dépôt et +# renseigner les clés IA ci-dessous — core/flask_app.py les charge lui- +# même (python-dotenv), rien d'autre à faire. Jamais committer ce ".env" +# (déjà dans .gitignore). +# - En PRODUCTION : ce fichier configure docker-compose.prod.yml (image, +# port) — copier en ".env" à côté de docker-compose.prod.yml sur le +# serveur. L'app elle-même n'y lit AUCUN fichier .env en production +# (aucun n'y est déployé) : les variables y sont posées directement sur +# l'hôte/le conteneur, docker-compose.prod.yml les lui transmettant. # Adresse complète de l'image dans le registre Gitea, ex : # gitea.exemple.com/mon-compte/forge-engine @@ -23,3 +29,25 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= SMTP_FROM= + +# Onglet "IA" (voir ai/client.py) — clé du compte Anthropic Console +# (console.anthropic.com), PAS l'abonnement claude.ai Entreprise (deux +# systèmes de facturation séparés). Laisser vide désactive le chat IA +# (message d'erreur clair affiché au créateur, jamais une 500). +ANTHROPIC_API_KEY= + +# Génération d'images (voir ai/scenario_client.py) — compte Scenario +# (scenario.com), clé + secret (authentification Basic), et l'id du +# modèle entraîné sur le style graphique unique de Forge Engine (voir +# l'étude préalable : un seul style pour tout le moteur, pas par projet). +# Laisser vide désactive juste la génération d'images (le reste du chat +# IA continue de fonctionner). +SCENARIO_API_KEY= +SCENARIO_API_SECRET= +SCENARIO_MODEL_ID= + +# Debogueur Werkzeug (traceback interactif + auto-reload) pour le +# developpement local uniquement — ne jamais activer ailleurs qu'en local +# (app.py, python app.py direct ; sans effet en production, qui utilise +# gunicorn). Laisser vide/0 = desactive par defaut. +FORGE_DEBUG=0 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..d6dba23c --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,191 @@ +{ + "root": true, + "env": { + "browser": true, + "es2021": true + }, + "extends": ["airbnb-base"], + "plugins": ["unused-imports"], + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "script" + }, + "rules": { + "max-len": ["error", 120, 2, { + "ignoreUrls": true, + "ignoreComments": false, + "ignoreRegExpLiterals": true, + "ignoreStrings": true, + "ignoreTemplateLiterals": true + }], + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "error", + { + "vars": "all", + "args": "after-used", + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^(_|addClauseRow|addPersonnageCommandExtraRow|applyCameraSize|bindClicks|bindHeldKeyTriggers|bindHoverTexts|bindHoverTriggers|bindKeyboardTriggers|bindPropsAutosave|closeLeftPanel|closePropsPanel|collisionWizardAssetNext|collisionWizardAttendreNext|collisionWizardChainDone|collisionWizardChooseAction|collisionWizardChooseBranch|collisionWizardChooseTrigger|collisionWizardConditionNext|collisionWizardIndicationNext|collisionWizardObjectTargetChosen|collisionWizardValeurChosen|collisionWizardVariableNext|collisionWizardVideoModeChosen|confirmDeleteElement|createGlobalVariable|deleteGlobalVariable|deleteSceneObject|deleteUserAsset|forgeShowTab|iaChatDeleteConversation|iaChatInputKeydown|iaChatSelectConversation|initSceneBuilderPanel|injectCustomKeyframes|onCanvasDragOver|onCanvasDrop|onCollisionBoxMouseDown|onCollisionBoxResizeMouseDown|onElementMouseDown|onFilterValueFieldChange|onFilterValueModeChange|onFilterValueObjChange|onFilterValueVarChange|onGalleryTileDragStart|onResizeMouseDown|onSceneObjectMouseDown|onSceneObjectResizeMouseDown|openAddTriggerModal|openLeftPanel|openPropsPanel|openScreenAddTriggerModal|openScreenTriggerWizard|openTriggerWizard|reclassifyUserAsset|refreshRuntimeData|restartPersonnageIdlePreview|restoreFloatPanelState|runScreenHeldKeyTriggers|runScreenShowTriggers|runScreenTimerTriggers|saveDialogueBoxStyle|saveGlobalVariable|saveSceneObjectName|saveSceneObjectRole|screenIndexById|screenTriggerAppendLeaf|screenTriggerDeleteAt|screenTriggerOpenAppendActionModal|selectQuizBoxDialogTemplate|selectQuizBoxPageTemplate|showScreen|swapPersonnageCharacter|swapSceneObjectCharacter|switchBuilderTab|syncAlign|toggleDashCreate|triggerAppendLeaf|triggerClickObjectCard|triggerDeleteTrigger|triggerInlineAddBubble|triggerInlineAddQuestion|triggerInlineDeleteBubble|triggerInlineSetBubbleAudio|triggerInlineSetBubbleSpeaker|triggerInlineSetQuestionChoiceCount|triggerInlineSetQuestionChoiceText|triggerInlineSetQuestionCorrectIndex|triggerInlineSetQuestionRewardAmount|triggerInlineUpdateBubbleText|triggerInlineUpdateQuestionText|triggerMoveChainAction|triggerOpenAppendActionModal|triggerRemoveChainAction|triggerSelectObject|uploadUserAsset)$" + } + ], + "no-unused-vars": "off", + "no-underscore-dangle": "off", + "func-names": "off", + "no-param-reassign": "off", + "no-cond-assign": ["error", "except-parens"], + "no-empty": ["error", { "allowEmptyCatch": true }], + "guard-for-in": "off", + "no-unused-expressions": "off", + "no-return-assign": "off", + "consistent-return": "off", + "no-bitwise": "off", + "no-plusplus": "off", + "no-restricted-syntax": "off", + "prefer-destructuring": "off", + "no-useless-concat": "off", + "no-console": "off", + "no-nested-ternary": "off", + "no-void": "off", + "no-continue": "off", + "implicit-arrow-linebreak": "off", + "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }] + }, + "overrides": [ + { + "files": ["static/js/**/__tests__/**/*.test.js"], + "env": { "node": true, "browser": true }, + "parserOptions": { "sourceType": "script" } + }, + { + "files": ["static/js/play/**/*.js"], + "rules": { + "import/extensions": "off", + "global-require": "off" + } + } + ], + "globals": { + "COLLISION_ACTION_LABELS": "readonly", + "COLLISION_CHAIN_CHOICES": "readonly", + "COLLISION_TRIGGER_LABELS": "readonly", + "CONDITION_OPERATOR_LABELS_MAP": "readonly", + "CURRENT_SCREEN_ID": "readonly", + "CURRENT_SELECTED_ID": "writable", + "DATA_OPERATION_LABELS_MAP": "readonly", + "DEFINITIONS_DATA": "readonly", + "ELEMENT_ADD_URL": "readonly", + "ELEMENT_ANIMATIONS_MAP": "readonly", + "ELEMENT_VISIBILITY_LABELS_MAP": "readonly", + "FORGE_PLAY_URLS": "readonly", + "GAME_SLUG": "readonly", + "GLOBAL_VARIABLE_NAMES": "readonly", + "SCENE_HEIGHT": "readonly", + "SCENE_OBJECT_NAMES": "writable", + "SCENE_OBJECT_NAMES_JSON": "readonly", + "SCENE_WIDTH": "readonly", + "SCREEN_EDIT_URL": "readonly", + "SCREEN_ID": "readonly", + "SURBRILLANCE_LABELS_MAP": "readonly", + "USER_ASSETS_OPTIONS": "readonly", + "VIDEO_MODE_LABELS_MAP": "readonly", + "_collisionWizard": "writable", + "_stopPersonnageIdlePreview": "readonly", + "applyObjectProperty": "readonly", + "applyOpenRowBindings": "readonly", + "applySelectionHighlight": "readonly", + "bindClicks": "readonly", + "bindHoverTexts": "readonly", + "bindHoverTriggers": "readonly", + "bindPropsAutosave": "readonly", + "clampSceneObjectPosition": "readonly", + "closeTriggerModal": "readonly", + "collisionRuleThumbHtml": "readonly", + "collisionWizardBuildLeafAction": "readonly", + "compareValues": "readonly", + "debouncedSubmitPropsForm": "readonly", + "evaluateConditionClause": "readonly", + "evaluateConditionNode": "readonly", + "forgeApplyAddRowActionOffline": "readonly", + "forgeApplyCtx": "readonly", + "forgeApplyDataActionOffline": "readonly", + "forgeApplyScoreActionOffline": "readonly", + "forgeApplyStatusActionOffline": "readonly", + "forgeApplyVariableActionOffline": "readonly", + "forgeAttrString": "readonly", + "forgeAutoId": "readonly", + "forgeCollisionRectFromBox": "readonly", + "forgeComputeNewValue": "readonly", + "forgeDecodeClauses": "readonly", + "forgeDialogueBoxState": "readonly", + "forgeEscapeHtml": "readonly", + "forgeFilterRepeaterRows": "readonly", + "forgeFilterRowsByClauses": "readonly", + "forgeHtmlEscape": "readonly", + "forgeIsElementVisibleOffline": "readonly", + "forgeParentFlexDirection": "readonly", + "forgeQuizBoxState": "readonly", + "forgeQuizTemplateEffects": "readonly", + "forgeRecomputeFullPayloadOffline": "readonly", + "forgeRenderCheckboxOrRadio": "readonly", + "forgeRenderChildren": "readonly", + "forgeRenderElementHtml": "readonly", + "forgeRenderFieldset": "readonly", + "forgeRenderIcone": "readonly", + "forgeRenderJauge": "readonly", + "forgeRenderOnglets": "readonly", + "forgeRenderOverlay": "readonly", + "forgeRenderPersonnage": "readonly", + "forgeRenderRepeater": "readonly", + "forgeRenderSelect": "readonly", + "forgeRenderTable": "readonly", + "forgeResolveFilterValue": "readonly", + "forgeResolveVariablePath": "readonly", + "forgeRunCollisionRuleAction": "readonly", + "forgeRunScreenTriggers": "readonly", + "forgeScorm2004NotifyQuestionAnswered": "readonly", + "forgeScormApi": "readonly", + "forgeShapesOverlap": "readonly", + "forgeShowDialogueBox": "readonly", + "forgeShowVideoOverlay": "readonly", + "forgeStartCollisionRuleControllers": "readonly", + "forgeStartPersonnageControllers": "readonly", + "forgeStyleString": "readonly", + "forgeUpdateAllScoreWidgets": "readonly", + "forgeVisibleAttrs": "readonly", + "forgeXapiNotifyDialogueCompleted": "readonly", + "forgeXapiNotifyQuestionAnswered": "readonly", + "forgeXapiNotifyScoreChanged": "readonly", + "forgeXapiNotifyStatusChanged": "readonly", + "gameData": "writable", + "goToSelected": "readonly", + "heldKeys": "readonly", + "initBuilderPanel": "readonly", + "initIaTab": "readonly", + "initSceneBuilderPanel": "readonly", + "initTriggersTab": "readonly", + "openPropsPanel": "readonly", + "openScreenTriggerWizard": "readonly", + "openTriggerWizard": "readonly", + "refreshRuntimeData": "readonly", + "renderCollisionWizardChainStep": "readonly", + "resolveSpriteFrames": "readonly", + "restartPersonnageIdlePreview": "readonly", + "restoreFloatPanelState": "readonly", + "restoreTreeCollapsedState": "readonly", + "runActionNode": "readonly", + "runFlowFrom": "readonly", + "runScreenHeldKeyTriggers": "readonly", + "runScreenShowTriggers": "readonly", + "runScreenTimerTriggers": "readonly", + "runSpriteAnimation": "readonly", + "saveGeometry": "readonly", + "screenIndexById": "readonly", + "screenTriggerLoadAll": "readonly", + "screensData": "writable", + "showScreen": "readonly", + "startAllPersonnagePreviews": "readonly", + "stopAllSpriteAnimations": "readonly", + "submitPropsForm": "readonly", + "triggerLoadAll": "readonly" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ac1a3e91 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Force LF partout, quel que soit le core.autocrlf de la machine locale +# (Windows le met souvent a true par defaut) — sans ca, un `git stash`/ +# checkout (declenche par exemple par les hooks pre-commit avant de tester +# le diff stage) reconvertit les fichiers en CRLF, ce qu'ESLint refuse +# ensuite (regle linebreak-style: LF, voir .eslintrc.json) alors que le +# fichier sur disque, lui, est deja en LF — decouvert en committant la +# Phase 3 du plan qualite. +* text=auto eol=lf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 54894c7a..642f4120 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -4,12 +4,26 @@ on: push: branches: [main, dev] -# Le job "test" tourne sur CHAQUE push (main et dev) : jusqu'ici aucune -# étape de CI n'exécutait la suite de tests, rien n'empêchait un commit -# cassé d'atteindre la production (voir l'audit qualité de la Phase 0 du -# plan). "build-and-push"/"deploy", eux, restent réservés à main (via le -# filtre "if" sur gitea.ref) — un push sur dev ne doit jamais redéployer -# la prod, seulement faire tourner les tests. +# Les jobs "test-*"/"lint-*"/"sonarqube" tournent sur CHAQUE push (main et +# dev) : jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les +# outils qualité, rien n'empêchait un commit cassé d'atteindre la +# production (voir l'audit qualité de la Phase 0 du plan). "build-and- +# push"/"deploy", eux, restent réservés à main (via le filtre "if" sur +# gitea.ref) — un push sur dev ne doit jamais redéployer la prod, seulement +# faire tourner tests/lint/sonar. +# +# lint-python/lint-js rejouent EXACTEMENT les hooks pre-commit locaux +# (.pre-commit-config.yaml) mais bloquants ici dès le départ (déjà tous +# verts en local, voir le commit "Phase 3 : hardening qualite de code") — +# les hooks pre-commit ne protègent que la machine du committeur, jamais +# un push direct ou une PR mergée depuis ailleurs. djlint EXCLU +# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir +# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube, +# lui, reste NON-BLOQUANT (continue-on-error) pendant cette première +# période — voir CODE_QUALITY.md pour la trajectoire vers un mode +# bloquant une fois le rapport trié (code smells, vulnerabilites) plutôt +# que de bloquer tout de suite sur des centaines de signalements pas +# encore triés. # Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) : # REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com) @@ -20,6 +34,8 @@ on: # DEPLOY_USER utilisateur SSH sur ce serveur # DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur # DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine) +# SONAR_TOKEN jeton d'analyse SonarQube (Mon compte > Security > Generate Token +# sur sonar.forgebase.fr) — jamais le mot de passe admin. # # Utilise directement docker/ssh/scp en ligne de commande plutôt que des # actions du marketplace, pour ne pas dépendre de la disponibilité de @@ -68,17 +84,81 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation) + - name: Tests JS (node:test — logique pure de static/js/play/, et l'assistant déclencheurs de static/js/scenes/+static/js/triggers/, voir le plan de modularisation) run: | docker build -f - -t forge-test-js:${{ gitea.sha }} . <<'DOCKERFILE' FROM node:20-slim WORKDIR /app COPY static/js/play/ static/js/play/ - RUN node --test static/js/play/__tests__/*.test.js + COPY static/js/scenes/ static/js/scenes/ + COPY static/js/triggers/ static/js/triggers/ + RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js DOCKERFILE + lint-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Qualite Python (ruff/mypy/vulture/bandit/import-linter — memes commandes que .pre-commit-config.yaml) + run: | + # Meme neutralisation de .dockerignore que test-python ci-dessus : + # ruff/mypy typent aussi tests/ (voir le plan de typage strict), + # exclu par defaut de l'image de PROD. + mv .dockerignore .dockerignore.disabled-for-ci + docker build -f - -t forge-lint-python:${{ gitea.sha }} . <<'DOCKERFILE' + FROM python:3.13-slim + WORKDIR /app + COPY requirements.txt requirements-dev.txt ./ + RUN pip install --no-cache-dir -r requirements-dev.txt + COPY . . + RUN ruff check . + RUN ruff format --check . + RUN mypy . + RUN vulture + RUN bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py + RUN lint-imports + DOCKERFILE + + lint-js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Qualite JS/CSS (eslint/stylelint — memes commandes que .pre-commit-config.yaml, via les scripts npm de package.json) + run: | + docker build -f - -t forge-lint-js:${{ gitea.sha }} . <<'DOCKERFILE' + FROM node:20-slim + WORKDIR /app + COPY package.json package-lock.json ./ + RUN npm ci + COPY . . + RUN npm run lint:js + RUN npm run lint:css + DOCKERFILE + + sonarqube: + runs-on: ubuntu-latest + # Non-bloquant pendant cette premiere periode (voir le bloc de + # commentaires en tete de fichier) — un echec ici n'empeche jamais + # build-and-push/deploy, contrairement a lint-python/lint-js. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - name: Analyse SonarQube (rapport seul, non-bloquant) + run: | + # Meme neutralisation de .dockerignore que test-python : sonar- + # project.properties couvre aussi tests/ (sonar.tests). + mv .dockerignore .dockerignore.disabled-for-ci + docker build -f - -t forge-sonar:${{ gitea.sha }} . <<'DOCKERFILE' + FROM sonarsource/sonar-scanner-cli:latest + WORKDIR /usr/src + COPY . . + DOCKERFILE + docker run --rm forge-sonar:${{ gitea.sha }} \ + -Dsonar.host.url=https://sonar.forgebase.fr \ + -Dsonar.token=${{ secrets.SONAR_TOKEN }} + build-and-push: - needs: [test-python, test-js] + needs: [test-python, test-js, lint-python, lint-js] if: gitea.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 9d4d5502..34dfec08 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,16 @@ __pycache__/ *.egg-info/ .pytest_cache/ +# JS (voir package.json) — installe via `npm install`, jamais committé. +node_modules/ + +# Rapports d'outils qualité (ruff/bandit/eslint/stylelint/mypy/vulture/ +# import-linter/djlint — voir sonar-project.properties, CODE_QUALITY.md) : +# sortie regenerée à la demande avant chaque scan Sonar, jamais figée dans +# l'historique (un rapport committé devient vite mensonger — vécu : ceux du +# 14 sept re-signalaient des centaines de problèmes déjà corrigés). +reports/ + # Environnements virtuels .venv/ venv/ @@ -18,6 +28,10 @@ regles/ # Jeux créés par les utilisateurs (données runtime, pas du code) projects/ +# "Mes assets" — images par compte (données runtime, voir +# auth/user_assets_dir.py, db/constants.py::USER_ASSETS_DIR) +user_assets/ + # Comptes utilisateurs (base SQLite + clé de session) — données runtime, # jamais du code, et sensibles (mots de passe hachés, secrets 2FA). data/ @@ -38,6 +52,15 @@ Thumbs.db .vscode/ .idea/ +# Journaux de diagnostic ad-hoc (redirection stdout/stderr d'un serveur +# de dev lancé pour déboguer) — jamais du code. +_srv_out.txt +_srv_err.txt +_diag_out.txt +_diag_err.txt +server_out.log +server_err.log + # Documents internes/business (cadrage produit...) et état local de # session Claude Code — jamais du code, pas destiné à l'historique partagé. .claude/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..07cf6fcc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,80 @@ +# Hooks locaux (language: system) plutot que les miroirs pre-commit +# habituels (astral-sh/ruff-pre-commit, pre-commit/mirrors-mypy, +# pre-commit/mirrors-eslint...) : ce projet a deja un environnement +# Python (requirements-dev.txt) et un node_modules/ (package.json) bien +# a lui — repartir sur un environnement ISOLE et RE-TELECHARGE par +# pre-commit dupliquerait exactement les memes outils/versions pour rien +# (et serait plus lent, plus fragile hors ligne). Voir CODE_QUALITY.md +# pour l'installation prealable (`pip install -r requirements-dev.txt` +# + `npm install`), necessaire pour que ces hooks trouvent les +# executables. +# +# Chaque hook est bloquant (aucune regle desactivee "pour ne pas casser +# le build", demande explicite) — un commit est refuse si un outil +# trouve un probleme, jusqu'a ce que la Phase 3 du plan qualite ait +# nettoye l'existant. +# +# eslint/stylelint : entry appelle `node ` directement plutot +# que `npx eslint` ou le shim node_modules/.bin/eslint(.cmd) — ces deux +# dernieres formes font passer pre-commit par cmd.exe pour lancer un +# executable Windows (.cmd) ou un script a shebang POSIX (#!/bin/sh), +# et pre-commit resout mal cmd.exe/sh depuis ce contexte (bug constate +# sous Windows). Appeler `node` directement sur le fichier JS du package +# evite tout intermediaire shell. +repos: + - repo: local + hooks: + - id: ruff-check + name: Ruff (lint Python) + entry: ruff check + language: system + types: [python] + + - id: ruff-format + name: Ruff (formatage Python) + entry: ruff format --check + language: system + types: [python] + + - id: mypy + name: Mypy (typage strict Python) + entry: mypy . + language: system + types: [python] + pass_filenames: false + + - id: vulture + name: Vulture (code mort Python) + entry: vulture + language: system + pass_filenames: false + + - id: bandit + name: Bandit (securite Python) + entry: bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py + language: system + pass_filenames: false + + - id: import-linter + name: import-linter (contrats d'architecture Python) + entry: lint-imports + language: system + pass_filenames: false + + - id: djlint + name: djLint (templates Jinja) + entry: djlint templates + language: system + pass_filenames: false + + - id: eslint + name: ESLint (JS) + entry: node node_modules/eslint/bin/eslint.js + language: system + files: ^static/js/.*\.js$ + + - id: stylelint + name: Stylelint (CSS) + entry: node node_modules/stylelint/bin/stylelint.mjs + language: system + files: \.css$ diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 00000000..f1f8efad --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,12 @@ +{ + "extends": ["stylelint-config-standard"], + "ignoreFiles": [ + "static/vendor/**/*.css", + "static/style.css" + ], + "rules": { + "selector-class-pattern": null, + "selector-id-pattern": null, + "no-descending-specificity": null + } +} diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 00000000..43d54c1e --- /dev/null +++ b/ai/__init__.py @@ -0,0 +1,21 @@ +"""Couche IA (voir plan Phase 1) — infra transverse, distincte de +screens/ (rendu/données de jeu) : ai.tools définit les outils exposés à +l'agent (Phase 2) sans dépendre du SDK anthropic lui-même, pour rester +testable sans clé API.""" + +from .chat import run_chat_turn +from .client import MODEL, AnthropicNotConfiguredError, get_client +from .scenario_client import ScenarioGenerationError, ScenarioNotConfiguredError, generate_image_url +from .tools import TOOLS, dispatch_tool + +__all__ = [ + "TOOLS", + "dispatch_tool", + "get_client", + "AnthropicNotConfiguredError", + "MODEL", + "generate_image_url", + "ScenarioNotConfiguredError", + "ScenarioGenerationError", + "run_chat_turn", +] diff --git a/ai/chat.py b/ai/chat.py new file mode 100644 index 00000000..f930ee85 --- /dev/null +++ b/ai/chat.py @@ -0,0 +1,341 @@ +"""Boucle tool-use (voir plan Phase 2, §5) — Claude Sonnet 5 pilote +UNIQUEMENT les tools de ai/tools.py, jamais d'écriture directe en base : +tout ce qu'un tour de chat produit est donc TOUJOURS relisable/ +modifiable dans l'éditeur normal (même garantie que la Phase 1).""" + +import json +from typing import Any + +import db +import screens + +from .client import MODEL, get_client +from .tools import TOOLS, dispatch_tool + +# Borne dure : jamais une boucle sans fin qui dépenserait sans fin si +# Claude s'entête à rappeler des outils (bug de prompt, tool qui échoue +# en boucle, etc.) — un écran complet (fond, personnages, widgets, +# déclencheur, ajustements) peut légitimement dépasser 8 appels. +_MAX_TOOL_ITERATIONS = 12 + +_SYSTEM_PROMPT = ( + "Tu t'appelles Ruby, l'assistante IA de Forge Engine — présente-toi " + "sous ce nom si on te le demande. " + "Tu aides un créateur à construire UN SEUL écran d'un jeu de " + "formation professionnelle 2D (Forge Engine), via les outils fournis " + "— jamais autrement. Ne touche qu'à cet écran, jamais aux autres. " + "Réutilise une variable globale déjà existante UNIQUEMENT si elle " + "représente EXACTEMENT la même information (ex. un score total " + "partagé par tout le jeu) — les variables sont globales à TOUT le " + 'jeu, visibles depuis n\'importe quel écran, donc un drapeau "terminé" ' + "propre à UN dialogue/quiz précis ne doit JAMAIS être partagé avec un " + "autre dialogue/quiz, même similaire, même sur un autre écran (bug " + 'corrigé : un quiz réutilisait par erreur le drapeau "terminé" d\'un ' + "AUTRE quiz sans lien, les rendant mutuellement incohérents). Donne à " + "chaque nouveau drapeau un nom qui identifie clairement CE qu'il " + "suit (ex. préfixé par le nom du personnage/objet concerné). " + "N'utilise add_generated_image " + "QUE pour des fonds ou des objets 2D isolés (meubles, plantes, décor) " + "— jamais pour un personnage ou un sprite animé, la génération " + "d'image n'est pas fiable pour ça. " + "\n\n" + "CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique " + "\"déclencheur -> action\" d'un objet de scène OU de l'écran entier " + "(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE " + 'système, séparé, de "flow" (nœuds/liaisons) pour changer d\'écran, ' + "les minuteurs récurrents, une animation de sprite — tu n'as AUCUN " + 'outil pour ça. Si une demande a besoin de cette partie-là ("passer ' + 'à l\'écran suivant", "après 5 secondes"...), fais quand même tout ' + "ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse " + "texte ce que tu n'as pas pu faire et pourquoi (cette partie doit " + "être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS " + "à répéter des appels d'outils pour une chose qu'aucun outil ne " + "permet, ça n'aboutira jamais." + "\n\n" + "DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : " + '"collision" (contact avec le personnage "joueur" — a besoin d\'un ' + 'joueur, voir JOUEUR ET PNJ), "clic" (l\'objet est cliqué/touché, ' + "aucun joueur requis — typique d'un panneau, un bouton, un objet " + "d'interface statique), \"survol\" (le pointeur survole l'objet, " + "aucun joueur requis — typique d'une info contextuelle affichée sans " + "action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS " + 'objet requis) : "affichage" — se déclenche dès que l\'écran ' + "apparaît, pour une narration/cinématique d'ouverture. " + "RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel " + 'déclencheur utiliser pour un élément donné (ex. "ajoute un panneau ' + "d'information\" sans dire si c'est au clic, au survol, ou dès " + "l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta " + "réponse texte avant d'agir. " + "NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, " + 'chaînables via "then" comme dialogue/variable) : "surbrillance" ' + "(met un objet en valeur, ex. pour guider l'attention du joueur vers " + 'la suite), "visibilite" (affiche/masque un objet, ex. débloquer un ' + 'élément), "son" (effet sonore ponctuel), "video" (joue une vidéo ' + 'de "Mes assets", en plein écran ou en incrustation — bloque la ' + 'suite de la chaîne jusqu\'à la fin, comme un dialogue), "indication" ' + "(bulle de texte courte près d'un objet, pour un conseil ponctuel — " + 'PAS pour une réplique de personnage, utilise "dialogue" pour ça), ' + '"attendre" (suspend la chaîne "then" pendant data_value SECONDES ' + "avant de continuer — utile pour laisser un temps de lecture après " + "une narration à l'affichage de l'écran, avant d'enchaîner sur un " + "dialogue/une autre action)." + "\n\n" + "ÉTAT DE LA SCÈNE : le message système de chaque tour te donne les " + "dimensions de la caméra, la liste des objets déjà posés sur CET " + "écran, et les variables globales déjà existantes — relis-les avant " + "d'agir plutôt que de deviner (ne recrée jamais un objet ou une " + "variable qui existe déjà, corrige/complète l'existant avec " + "set_object_geometry/set_object_name/set_object_role/set_collision_rules). " + "Pour retirer ou réordonner UNE action précise d'une chaîne déjà posée " + "(sans reconstruire toute la règle), utilise remove_trigger_action/" + "move_trigger_action (objet) ou remove_screen_trigger_action/" + "move_screen_trigger_action (écran) avec le leaf_id concerné. " + "append_action_to_trigger/append_action_to_screen_trigger peuvent " + "INSÉRER une action à N'IMPORTE QUEL niveau d'une chaîne (pas " + "seulement à la toute fin) : after_id désigne le bloc juste AVANT " + "l'endroit où insérer." + "\n\n" + "CAMÉRA ET POSITIONNEMENT : les dimensions indiquées sont la zone " + "VISIBLE (coin haut-gauche à (0,0)) — place tout objet important " + "(personnages, widgets d'interface) DANS cette zone par défaut. Le " + "moteur RAMÈNE automatiquement dans le cadre toute position qui en " + 'sortirait (voir un éventuel champ "note" dans le résultat de ' + "set_object_geometry/add_scene_object — c'est déjà corrigé, rien à " + "refaire), donc vise une position raisonnable sans stresser sur le " + "pixel exact. Ne superpose jamais deux objets aux mêmes coordonnées " + "— espace-les clairement. Un personnage fraîchement posé fait " + "128x128 px par défaut — garde des tailles cohérentes entre " + 'personnages sauf besoin explicite. RÈGLE FIXE pour un "fond" : ' + "redimensionne-le TOUJOURS automatiquement en 2000x1000 px " + "(set_object_geometry, position (0,0)) juste après l'avoir posé, " + "SANS que le créateur ait besoin de le demander à chaque fois — " + "c'est le format standard de Forge Engine. Ne dépasse cette taille " + "que si le créateur demande explicitement un monde à explorer plus " + "grand." + "\n\n" + "JOUEUR ET PNJ : un personnage fraîchement posé a TOUJOURS le rôle " + '"pnj" par défaut, JAMAIS "joueur". Un déclencheur de type ' + '"collision" ne se déclenche QUE par le contact du personnage au ' + 'rôle "joueur" — sans lui, la collision ne se déclenche jamais ET ' + "la caméra n'a personne à suivre. Dès qu'un écran a besoin d'un " + "déclencheur de collision (quiz, dialogue déclenché en marchant " + "vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN " + 'personnage avec role="joueur" (set_object_role) — jamais deux, ' + "et ne pose jamais deux PNJ identiques sans que le créateur l'ait " + "demandé. " + "RÈGLE IMPORTANTE : si le créateur ne précise pas comment un " + 'déclencheur "collision" doit se comporter, demande-lui si ' + "l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le " + 'joueur doit d\'abord APPUYER SUR UNE TOUCHE (action "interagir", ' + 'qui affiche "Appuie sur [touche]" tant que le contact dure) avant ' + "que l'action ne se déclenche — ne suppose jamais l'un ou l'autre " + "toi-même sur une scène avec joueur+collision." + "\n\n" + "QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une " + "donnée consultée plus tard (progression, un choix du joueur, un " + 'drapeau "terminé") — JAMAIS pour un score de quiz (voir ' + "reward_amount plus haut, déjà automatique). Modifie une variable " + '(action "variable") quand un événement doit changer durablement ' + 'cet état (ex. marquer un drapeau "xxx_termine" à vrai une fois un ' + "quiz fini). Utilise une CONDITION quand le comportement doit " + "VRAIMENT différer selon l'état actuel d'une variable — un simple " + "enchaînement linéaire n'a besoin que d'un chaînage \"then\", jamais " + "d'une condition. CAS CANONIQUE à connaître : un dialogue/quiz à " + "USAGE UNIQUE (ex. un PNJ qui pose un quiz une seule fois) doit être " + "protégé par une CONDITION qui vérifie D'ABORD le drapeau " + '"xxx_termine" — si faux (pas encore fait), lance le dialogue/quiz ' + 'normal (branche si_faux) PUIS termine par une action "variable" ' + "qui passe ce drapeau à vrai ; si vrai (déjà fait), réponds par une " + "réplique courte différente (branche si_vrai) au lieu de rejouer " + "tout le quiz à chaque collision. Utilise un DIALOGUE pour toute " + "réplique ou question posée au joueur." + "\n\n" + "IMPORTANT — UN DIALOGUE/QUIZ/SCORE NE S'AFFICHE JAMAIS TOUT SEUL EN " + "JEU : il faut TOUJOURS poser en plus le widget d'interface " + "correspondant sur l'écran via add_scene_object, sinon rien n'apparaît " + "à l'écran même si le déclencheur est correctement configuré. Une " + 'action "dialogue" dont les lignes sont de type "replique" a besoin ' + 'd\'un objet kind="dialogue_box" ; une action "dialogue" dont les ' + 'lignes sont de type "question" (quiz) a besoin d\'un objet ' + 'kind="quiz_box" (pas dialogue_box) ; si un score/des points sont ' + 'utilisés, ajoute aussi un objet kind="score_widget" pour qu\'il soit ' + "visible en continu. Positionne ces widgets à un endroit raisonnable " + "de l'écran (ex. centré, ou en haut) via set_object_geometry après " + "les avoir posés." + "\n\n" + "QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit " + 'un besoin de quiz "tout seul" (pas un personnage/dialogue de jeu), ' + 'utilise set_quiz_box_config sur l\'objet kind="quiz_box" pour régler ' + "le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : " + "jamais imposé par défaut — demande TOUJOURS si le créateur en veut un " + 'avant d\'en activer un, "question" redémarre à chaque question, ' + '"quiz" est un seul compte à rebours pour tout le quiz, timer_seconds ' + "sa durée)." + "\n\n" + "MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : " + 'dialog_template (modèles "boîte de dialogue" : "defaut" — Classique — ' + 'ou "manga_dialogue", le pendant en petite carte du thème manga) ne ' + "s'affiche QUE si fullscreen=false. " + 'page_template (modèles "page de quiz" : "classique" — sobre/' + 'professionnel, couleurs reprises du modèle "boîte de dialogue" de ' + 'base — ou "manga", un thème dessiné/typographié entièrement à part) ' + "ne s'affiche QUE si fullscreen=true — chacun un thème complet et " + "autonome (police, formes, couleurs ET structure entièrement propres à " + "ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux " + "réglages sont conservés " + "INDÉPENDAMMENT (jamais l'un n'écrase l'autre) : ne règle QUE celui qui " + "correspond au mode (plein écran ou non) réellement voulu par le " + "créateur, et demande le style souhaité s'il n'en a mentionné aucun " + "plutôt que d'en choisir un au hasard. En plein écran, le score choisi " + "via score_widget/reward_amount s'affiche automatiquement DANS la boîte " + "à quiz, sans réglage supplémentaire." + "\n\n" + "Ton professionnel, adapté à une formation d'entreprise. Réponds " + "toujours en français, de façon concise, en confirmant ce que tu as " + "posé." +) + + +class ScreenDeletedError(Exception): + """Garde-fou défensif pour screen_id introuvable : en usage normal, + supprimer un écran (screens/screens_repo/delete_screen.py) supprime + déjà EN CASCADE ses conversations IA (ON DELETE CASCADE, voir + screens/ia/ensure_ia_chat_schema.py) — cette voie n'est donc pas + censée être atteignable via l'appli. get_screen() renvoyant + dict | None, ce garde évite quand même un crash cru (attribut sur + None) si jamais screen_id était invalide pour une autre raison, + plutôt qu'un message lisible par un humain (voir run_chat_turn).""" + + +def _describe_scene_state(slug: str, screen_id: int) -> str: + """Contexte dynamique (jamais mémorisé côté Claude entre les tours, + voir run_chat_turn — seul le texte final est persisté) : sans ça, + Ruby ne "voit" jamais ce qui existe déjà sur l'écran et duplique des + objets au lieu de les corriger (bug observé : 2 PNJ identiques créés + à la place d'un seul joueur + un pnj).""" + screen = screens.get_screen(slug, screen_id) + if screen is None: + raise ScreenDeletedError("L'écran de cette conversation a été supprimé.") + width, height = screen["scene_width"], screen["scene_height"] + objects = screens.list_scene_objects(slug, screen_id) + # Répété ICI (pas seulement dans les instructions générales) avec les + # VRAIS chiffres de cet écran — un rappel abstrait une seule fois dans + # un long system prompt s'est montré insuffisant (bug observé deux + # fois : personnage posé à des coordonnées bien au-delà de la caméra). + lines = [ + f"Zone visible par la caméra CETTE ÉCRAN : x de 0 à {width}, y de 0 à {height} " + f"(coin haut-gauche à (0,0)). RAPPEL : place tout personnage/widget d'interface " + f"À L'INTÉRIEUR de ces bornes par défaut (ex. x autour de {width // 2}, " + f"y autour de {height // 2} pour un centrage simple), sauf demande explicite d'un " + "monde plus grand à explorer." + ] + if not objects: + lines.append("Aucun objet posé sur cet écran pour l'instant.") + else: + lines.append(f"{len(objects)} objet(s) déjà posé(s) sur cet écran :") + for o in objects: + desc = ( + f"- id={o['id']} kind={o['kind']} position=({int(o['x'])},{int(o['y'])}) " + f"taille={int(o['width'])}x{int(o['height'])}" + ) + if o.get("name"): + desc += f" nom={o['name']!r}" + if o["kind"] == "personnage": + desc += f" rôle={screens.resolve_personnage_role(o)}" + if (o.get("attributes") or {}).get("_collision_rules"): + desc += " [déclencheur déjà configuré]" + lines.append(desc) + + screen_triggers = screens.resolve_screen_triggers(screen) + if screen_triggers: + lines.append( + f"{len(screen_triggers)} déclencheur(s) D'ÉCRAN déjà configuré(s) sur cet écran " + "(voir set_screen_triggers) — relis-les avant d'en ajouter un nouveau plutôt que " + "de dupliquer une narration d'ouverture déjà posée." + ) + else: + lines.append("Aucun déclencheur d'écran (narration à l'affichage) configuré pour l'instant.") + + variables = db.list_global_variables(slug) + if variables: + lines.append("Variables globales déjà existantes dans ce jeu (jamais en recréer une du même nom) :") + for v in variables: + lines.append(f"- {v['name']} ({v['type']}, valeur actuelle : {v['value']})") + else: + lines.append("Aucune variable globale n'existe encore dans ce jeu.") + return "\n".join(lines) + + +def _history_to_messages(history: list[dict[str, Any]]) -> list[Any]: + return [{"role": m["role"], "content": m["content"]} for m in history] + + +def run_chat_turn(slug: str, screen_id: int, conversation_id: int, user_id: int, user_message: str) -> str: + """Un tour complet : reprend l'historique persisté de CETTE + conversation, ajoute le message du créateur, boucle tant que Claude + appelle des outils, et renvoie le texte final. `screen_id` reste + nécessaire pour les tools (voir ai/tools.py — chaque conversation + reste scopée à SON écran, une conversation ne change jamais + d'écran). Ne persiste RIEN elle-même — voir routes/ia/ia_chat.py, + seul appelant, qui décide de ce qui est sauvegardé (même séparation + que le reste du moteur : cette fonction ne fait que la logique IA).""" + try: + # État réel de la scène RE-LU à chaque tour (jamais mémorisé par + # Claude lui-même) — voir _describe_scene_state. Vérifié AVANT de + # construire le client Claude : pas la peine d'appeler l'API si + # l'écran de cette conversation n'existe plus. + scene_state = _describe_scene_state(slug, screen_id) + except ScreenDeletedError: + return "Cet écran a été supprimé — cette conversation n'est plus utilisable." + client = get_client() # AnthropicNotConfiguredError si pas de clé + messages: list[Any] = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id)) + messages.append({"role": "user", "content": user_message}) + system_prompt = _SYSTEM_PROMPT + "\n\n" + scene_state + + response = None + for _ in range(_MAX_TOOL_ITERATIONS): + # 4096 était trop bas (bug corrigé) : la réflexion adaptative + # partage le même budget que la réponse — sur une demande riche + # (plusieurs objets + logique + texte), Claude pouvait être coupé + # EN PLEINE RÉFLEXION, avant le moindre appel d'outil (symptôme + # observé : aucune progression du tout, "(pas de réponse + # textuelle)" dès le premier tour). + response = client.messages.create( # type: ignore[call-overload] # TOOLS/messages sont des dict Python bruts, pas les TypedDict exacts du SDK anthropic + model=MODEL, + max_tokens=16000, + system=system_prompt, + tools=TOOLS, + thinking={"type": "adaptive"}, + messages=messages, + ) + messages.append({"role": "assistant", "content": response.content}) + if response.stop_reason != "tool_use": + break + tool_results: list[Any] = [] + for block in response.content: + if block.type == "tool_use": + try: + result = dispatch_tool(slug, screen_id, user_id, block.name, block.input) + except Exception as e: + result = {"error": str(e)} + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": json.dumps(result), + } + ) + messages.append({"role": "user", "content": tool_results}) + + assert response is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction + "_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois" + ) + text = next((b.text for b in response.content if b.type == "text"), "") + if text: + return text + if response.stop_reason == "max_tokens": + return ( + "Ruby a été interrompue avant de terminer (demande trop complexe pour une seule " + "réponse) — réessaie en la découpant en plusieurs étapes plus simples." + ) + return "(pas de réponse textuelle)" diff --git a/ai/client.py b/ai/client.py new file mode 100644 index 00000000..cbf4df10 --- /dev/null +++ b/ai/client.py @@ -0,0 +1,22 @@ +"""Client Claude (voir plan Phase 2, §2) — même politique que +auth/send_email.py::EmailNotConfiguredError : une clé absente est une +configuration incomplète, jamais un crash brut ni une clé en dur.""" + +import os + +import anthropic + +MODEL = "claude-sonnet-5" + + +class AnthropicNotConfiguredError(Exception): + """Levée quand ANTHROPIC_API_KEY est absente — voir .env.example. + Le compte claude.ai Entreprise (chat en équipe) n'est PAS une clé API : + il faut un compte Anthropic Console (console.anthropic.com) séparé.""" + + +def get_client() -> anthropic.Anthropic: + api_key = os.environ.get("ANTHROPIC_API_KEY") + if not api_key: + raise AnthropicNotConfiguredError("ANTHROPIC_API_KEY absente") + return anthropic.Anthropic(api_key=api_key) diff --git a/ai/scenario_client.py b/ai/scenario_client.py new file mode 100644 index 00000000..bb85ee36 --- /dev/null +++ b/ai/scenario_client.py @@ -0,0 +1,73 @@ +"""Client Scenario (voir plan Phase 2, §3) — génération d'images (fonds/ +objets 2D uniquement, jamais de personnages/sprites — décision actée +lors de l'étude préalable). API confirmée (docs.scenario.com) : +authentification Basic (clé + secret), génération ASYNCHRONE par job — +POST déclenche un job, GET /jobs/{id} jusqu'à "success"/"failure", puis +GET /assets/{id} pour l'URL finale téléchargeable. + +Ce module reste un simple client HTTP : il renvoie l'URL de l'image, +jamais ne la télécharge/l'enregistre lui-même — ça reste le travail de +ai/tools.py::_dispatch_add_generated_image (voir "Mes assets", +auth/user_assets_dir.py), pour que ce module soit testable seul.""" + +import os +import time + +import requests + +_BASE_URL = "https://api.cloud.scenario.com/v1" + + +class ScenarioNotConfiguredError(Exception): + """SCENARIO_API_KEY/SCENARIO_API_SECRET/SCENARIO_MODEL_ID absents — + voir .env.example. SCENARIO_MODEL_ID est l'id du modèle entraîné sur + le style graphique unique de Forge Engine (pas encore créé tant que + l'entraînement Scenario n'a pas été fait).""" + + +class ScenarioGenerationError(Exception): + """Le job Scenario a échoué ou n'a pas répondu à temps.""" + + +def _credentials() -> tuple[tuple[str, str], str]: + api_key = os.environ.get("SCENARIO_API_KEY") + api_secret = os.environ.get("SCENARIO_API_SECRET") + model_id = os.environ.get("SCENARIO_MODEL_ID") + if not api_key or not api_secret or not model_id: + raise ScenarioNotConfiguredError( + "SCENARIO_API_KEY/SCENARIO_API_SECRET/SCENARIO_MODEL_ID absents — voir .env.example." + ) + return (api_key, api_secret), model_id + + +def generate_image_url( + prompt: str, width: int = 768, height: int = 768, timeout: int = 120, poll_interval: int = 2 +) -> str: + """Lance une génération txt2img et attend le résultat — renvoie + l'URL finale de l'image (hébergée par Scenario, à télécharger par + l'appelant). Bloquant (poll_interval secondes entre chaque + vérification), borné par `timeout` secondes au total.""" + auth, model_id = _credentials() + response = requests.post( + f"{_BASE_URL}/generate/txt2img", + auth=auth, + timeout=30, + json={"prompt": prompt, "modelId": model_id, "width": width, "height": height, "numSamples": 1}, + ) + response.raise_for_status() + job_id = response.json()["job"]["jobId"] + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + status_response = requests.get(f"{_BASE_URL}/jobs/{job_id}", auth=auth, timeout=30) + status_response.raise_for_status() + job = status_response.json()["job"] + if job["status"] == "success": + asset_id = job["metadata"]["assetIds"][0] + asset_response = requests.get(f"{_BASE_URL}/assets/{asset_id}", auth=auth, timeout=30) + asset_response.raise_for_status() + return str(asset_response.json()["asset"]["url"]) + if job["status"] == "failure": + raise ScenarioGenerationError("Scenario a échoué à générer l'image.") + time.sleep(poll_interval) + raise ScenarioGenerationError("Scenario n'a pas répondu à temps.") diff --git a/ai/tools.py b/ai/tools.py new file mode 100644 index 00000000..618d55c4 --- /dev/null +++ b/ai/tools.py @@ -0,0 +1,770 @@ +"""Outils exposés à l'agent IA (voir plan Phase 1, §4) — chaque tool +appelle DIRECTEMENT une fonction déjà utilisée par l'éditeur no-code +manuel, jamais une structure parallèle : ce qu'un créateur voit ensuite +dans l'éditeur (Déclencheurs, Variables, panneau de propriétés) est donc +TOUJOURS le résultat du même code, qu'il ait été posé à la main ou par +l'IA. + +Aucune dépendance au SDK anthropic ici (voir ai/__init__.py) — ce module +ne fait que décrire les schémas et les relier à screens.*/db.* ; la +boucle tool-use elle-même (Phase 2) l'utilisera tel quel. + +Principe validé avec l'utilisateur : un NOUVEL outil reste une décision +explicite (jamais de réflexion automatique sur tout screens/), mais une +VALEUR interne à un outil existant (un type d'action, un opérateur de +condition, une opération de variable) doit suivre automatiquement dès +qu'elle est ajoutée côté moteur — d'où les schémas ci-dessous construits +à partir des constantes existantes (ACTION_TYPES, CONDITION_OPERATORS, +DATA_OPERATION_LABELS, ...) plutôt que recopiées en dur. Un test dédié +(tests/test_ai_tools.py) vérifie que cette référence n'est jamais +remplacée par une copie littérale.""" + +import os +from typing import Any, Callable + +import requests +from flask import url_for + +import auth +import db +import screens +from db.dialogue_lines import QUESTION_REWARD_TYPES +from screens.labels.data_operations import DATA_OPERATION_LABELS +from screens.labels.element_visibility import ELEMENT_VISIBILITY_LABELS +from screens.labels.surbrillance_values import SURBRILLANCE_LABELS +from screens.labels.video_modes import VIDEO_MODE_LABELS +from screens.rendering.collision_rules import ACTION_TYPES, CONDITION_OPERATOR_KEYS, LEAF_ACTION_TYPES, TRIGGER_TYPES +from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN + +from .scenario_client import generate_image_url + +_CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS) +_DATA_OPERATIONS = sorted(DATA_OPERATION_LABELS) +_PERSONNAGE_ROLES = list(screens.PERSONNAGE_ROLES) +_COLLISION_SHAPES = list(screens.COLLISION_SHAPES) +_QUIZ_BOX_TIMER_MODES = list(screens.TIMER_MODES) +_QUIZ_BOX_DIALOG_TEMPLATES = list(screens.QUIZ_BOX_DIALOG_TEMPLATES) +_QUIZ_BOX_PAGE_TEMPLATES = list(screens.QUIZ_BOX_PAGE_TEMPLATES) +_GLOBAL_VARIABLE_TYPES = sorted(db.GLOBAL_VARIABLE_TYPES) +# Catalogue COMPLET (public + admin-only, voir core/sprite_gate.py) — +# Claude doit connaître les slugs valides pour ne jamais en deviner un +# qui retomberait silencieusement sur le personnage par défaut +# (screens.add_scene_object). L'accès aux entrées admin-only reste +# vérifié à l'exécution (voir _dispatch_add_scene_object), même garde +# que la galerie manuelle. +_FORGE_CHARACTERS = sorted(screens.SPRITE_LIBRARY) +_BACKGROUNDS = sorted(screens.BACKGROUND_LIBRARY) + +_LEAF_ACTION_SCHEMA: dict[str, Any] = { + "type": "object", + "description": ( + 'Une action FEUILLE ("dialogue" ou "variable", voir ' + "screens/rendering/collision_rules.py) — peut porter un champ " + 'optionnel "then" (une autre feuille, chaînage borné à 4).' + ), + "properties": { + "type": {"type": "string", "enum": list(LEAF_ACTION_TYPES)}, + "id": { + "type": "string", + "description": "Identifiant de cette feuille, pour l'adresser plus tard via append_action_to_trigger.", + }, + "dialogue": { + "type": "object", + "description": ( + "Pour type=dialogue : {id, lines}. Chaque élément de `lines` est SOIT une " + 'RÉPLIQUE {"type":"dialogue", "speaker": , "text": }, ' + 'SOIT une QUESTION DE QUIZ {"type":"question", "text", "choices": ' + '[2 à 4 réponses], "correct_index": , ' + f'"reward_type": {list(QUESTION_REWARD_TYPES)!r}, "reward_amount": }}. ' + "reward_amount alimente le SCORE NATIF du jeu (visible via un objet " + 'kind="score_widget", nécessite aussi un objet kind="quiz_box" posé pour que ' + "la question s'affiche) — crédité UNIQUEMENT si la réponse est correcte, sinon " + "ignoré ; le joueur avance toujours à la ligne suivante, bonne réponse ou pas. " + "N'invente JAMAIS une variable séparée pour suivre un score de quiz : " + 'reward_amount fait déjà tout, sans action "variable" supplémentaire.' + ), + "properties": {"id": {"type": "string"}, "lines": {"type": "array"}}, + }, + "mark_completed": {"type": "boolean"}, + "target_variable": { + "type": "string", + "description": "Pour type=variable : nom d'une variable globale existante.", + }, + "data_operation": {"type": "string", "enum": _DATA_OPERATIONS}, + "data_value": { + "type": "string", + "description": ( + "Pour type=variable : absent pour definir_bool_vrai/definir_bool_faux/basculer_bool. " + 'Pour type=attendre : nombre de SECONDES à attendre avant "then" (ex. "2", "1.5").' + ), + }, + "object_id": { + "type": "integer", + "description": ( + "Pour type=surbrillance/visibilite/indication : id de l'objet CIBLÉ par cette " + "action — n'importe quel objet de l'écran, pas forcément celui qui porte le " + "déclencheur (ex. le joueur touche un interrupteur -> indication près d'une porte)." + ), + }, + "valeur": { + "type": "string", + "description": ( + f"Pour type=surbrillance : une valeur parmi {sorted(SURBRILLANCE_LABELS)!r}. " + f"Pour type=visibilite : une valeur parmi {sorted(ELEMENT_VISIBILITY_LABELS)!r}. " + "Pour type=condition (voir _ACTION_SCHEMA) : la valeur littérale à comparer." + ), + }, + "asset_url": { + "type": "string", + "description": 'Pour type=son/video : URL d\'un fichier déjà présent dans "Mes assets".', + }, + "mode": {"type": "string", "enum": sorted(VIDEO_MODE_LABELS), "description": "Pour type=video."}, + "texte": {"type": "string", "description": "Pour type=indication : le texte affiché dans la bulle."}, + "duree_ms": {"type": "integer", "description": "Pour type=indication : durée d'affichage en ms (optionnel)."}, + "then": {"description": "Feuille suivante (même forme), récursif."}, + }, + "required": ["type"], +} + +_ACTION_SCHEMA: dict[str, Any] = { + "type": "object", + "description": ( + "Une action de déclencheur — voir screens/rendering/collision_rules.py pour la forme exacte de chaque type." + ), + "properties": { + "type": {"type": "string", "enum": list(ACTION_TYPES)}, + "id": {"type": "string"}, + "dialogue": _LEAF_ACTION_SCHEMA["properties"]["dialogue"], + "mark_completed": {"type": "boolean"}, + "target_variable": {"type": "string"}, + "data_operation": {"type": "string", "enum": _DATA_OPERATIONS}, + "data_value": {"type": "string"}, + "object_id": _LEAF_ACTION_SCHEMA["properties"]["object_id"], + "valeur": _LEAF_ACTION_SCHEMA["properties"]["valeur"], + "asset_url": _LEAF_ACTION_SCHEMA["properties"]["asset_url"], + "mode": _LEAF_ACTION_SCHEMA["properties"]["mode"], + "texte": _LEAF_ACTION_SCHEMA["properties"]["texte"], + "duree_ms": _LEAF_ACTION_SCHEMA["properties"]["duree_ms"], + "then": { + "description": ( + "Feuille suivante (dialogue/variable/surbrillance/visibilite/son/video/indication), récursif." + ), + }, + "sub_action": {"description": 'Pour type=interagir : une action (pas "interagir" à nouveau).'}, + "variable": {"type": "string", "description": "Pour type=condition."}, + "operateur": {"type": "string", "enum": _CONDITION_OPERATORS}, + "si_vrai": {"description": "Pour type=condition : null ou une feuille."}, + "si_faux": {"description": "Pour type=condition : null ou une feuille."}, + }, + "required": ["type"], +} + +TOOLS: list[dict[str, Any]] = [ + { + "name": "add_scene_object", + "description": ( + "Pose un nouvel objet sur l'écran en cours (personnage, décor, fond, " + 'ou widget d\'interface). Pour une image générée/de "Mes assets", ' + "utiliser image_url (jamais générer un personnage/sprite par ce biais)." + ), + "input_schema": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"], + }, + "forge_character": { + "type": "string", + "enum": _FORGE_CHARACTERS, + "description": ( + "Pour kind=personnage : slug d'un personnage de la bibliothèque Forge " + "existante (jamais un sprite généré)." + ), + }, + "background_slug": { + "type": "string", + "enum": _BACKGROUNDS, + "description": ( + "Pour kind=fond : slug d'une image de fond déjà existante dans la bibliothèque Forge." + ), + }, + "image_url": { + "type": "string", + "description": ( + 'URL d\'une image déjà uploadée/générée (voir "Mes assets") — kind ' + "decor/fond uniquement, prioritaire sur background_slug." + ), + }, + }, + "required": ["kind"], + }, + }, + { + "name": "set_object_geometry", + "description": "Positionne/redimensionne un objet déjà posé sur l'écran, en pixels.", + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "x": {"type": "number"}, + "y": {"type": "number"}, + "width": {"type": "number"}, + "height": {"type": "number"}, + }, + "required": ["object_id", "x", "y", "width", "height"], + }, + }, + { + "name": "set_object_name", + "description": ( + 'Donne un nom à un objet de scène (ex. "Stan", "Aka") — affiché comme ' + '"qui parle" dans les dialogues qui lui sont attachés.' + ), + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "name": {"type": "string"}, + }, + "required": ["object_id", "name"], + }, + }, + { + "name": "set_object_role", + "description": "Change le rôle d'un objet personnage (joueur / ennemie / pnj).", + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "role": {"type": "string", "enum": _PERSONNAGE_ROLES}, + }, + "required": ["object_id", "role"], + }, + }, + { + "name": "set_object_collision", + "description": "Règle la boîte de collision d'un objet (forme, taille, décalage, activée ou non).", + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "enabled": {"type": "boolean"}, + "shape": {"type": "string", "enum": _COLLISION_SHAPES}, + "width": {"type": "number"}, + "height": {"type": "number"}, + "offset_x": {"type": "number"}, + "offset_y": {"type": "number"}, + }, + "required": ["object_id"], + }, + }, + { + "name": "set_quiz_box_config", + "description": ( + 'Règle les options d\'une "❓ Boîte à quiz" pensées pour un quiz AUTONOME ' + '(RH/formation) : plein écran, minuteur (jamais imposé — "aucun" par défaut, ' + 'au choix du créateur : "question" redémarre à chaque question, "quiz" est un ' + "seul compte à rebours pour tout le quiz), et DEUX modèles visuels INDÉPENDANTS " + "(les deux réglages sont conservés en même temps, jamais l'un n'écrase l'autre) : " + "dialog_template (visible SEULEMENT hors plein écran — variantes de forme/couleur " + 'sur une structure proche de "Classique") et page_template (visible SEULEMENT en ' + "plein écran — thème complet et autonome, structure HTML propre à chaque modèle). " + "En plein écran, le score se retrouve affiché directement dans la boîte." + ), + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "fullscreen": {"type": "boolean"}, + "timer_mode": {"type": "string", "enum": _QUIZ_BOX_TIMER_MODES}, + "timer_seconds": { + "type": "integer", + "description": 'Durée du minuteur (3 à 600s), ignorée si timer_mode="aucun".', + }, + "dialog_template": { + "type": "string", + "enum": _QUIZ_BOX_DIALOG_TEMPLATES, + "description": 'Modèle "boîte de dialogue" — ne s\'affiche que si fullscreen=false.', + }, + "page_template": { + "type": "string", + "enum": _QUIZ_BOX_PAGE_TEMPLATES, + "description": 'Modèle "page de quiz" — ne s\'affiche que si fullscreen=true.', + }, + }, + "required": ["object_id"], + }, + }, + { + "name": "create_global_variable", + "description": ( + "Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions." + ), + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "var_type": {"type": "string", "enum": _GLOBAL_VARIABLE_TYPES}, + "default_value": {"type": "string"}, + "per_player": {"type": "boolean"}, + }, + "required": ["name"], + }, + }, + { + "name": "set_collision_rules", + "description": ( + 'Remplace TOUTES les règles "déclencheur -> action" d\'un objet. ' + 'Déclencheurs disponibles : "collision" (contact avec le personnage ' + '"joueur" — voir set_object_role), "clic" (l\'objet est cliqué/touché, ' + 'aucun joueur requis), "survol" (le pointeur survole l\'objet, aucun ' + "joueur requis). Sanitizé côté serveur (screens.sanitize_collision_rules) : " + "toute valeur invalide est silencieusement retirée plutôt que rejetée — " + "compare le nombre de `rules` renvoyées dans le résultat à ce que tu as " + "envoyé pour détecter un retrait silencieux." + ), + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "trigger": {"type": "string", "enum": list(TRIGGER_TYPES)}, + "action": _ACTION_SCHEMA, + }, + "required": ["trigger", "action"], + }, + }, + }, + "required": ["object_id", "rules"], + }, + }, + { + "name": "set_screen_triggers", + "description": ( + "Remplace TOUTES les règles \"déclencheur d'écran -> action\" de l'écran " + "en cours — un déclencheur d'ÉCRAN se déclenche SANS avoir besoin d'un " + "objet (narration/cinématique dès l'affichage). Seul déclencheur : " + "\"affichage\" (à l'affichage de l'écran). Même sanitisation que " + "set_collision_rules." + ), + "input_schema": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "trigger": {"type": "string", "enum": list(TRIGGER_TYPES_SCREEN)}, + "action": _ACTION_SCHEMA, + }, + "required": ["trigger", "action"], + }, + }, + }, + "required": ["rules"], + }, + }, + { + "name": "add_generated_image", + "description": ( + "Génère une image (fond ou objet 2D UNIQUEMENT, JAMAIS un " + "personnage/sprite — la génération d'image n'est pas fiable pour " + 'ça) via Scenario, l\'ajoute à "Mes assets" et la pose ' + "automatiquement sur l'écran en cours." + ), + "input_schema": { + "type": "object", + "properties": { + "kind": {"type": "string", "enum": ["decor", "fond"]}, + "prompt": {"type": "string", "description": "Description de l'image en langage naturel."}, + }, + "required": ["kind", "prompt"], + }, + }, + { + "name": "append_action_to_trigger", + "description": ( + "Insère une action JUSTE APRÈS un bloc précis d'une chaîne déjà " + "posée sur un déclencheur existant, sans reconstruire toute la " + "règle — after_id peut désigner N'IMPORTE QUEL bloc de la " + "chaîne, pas forcément le dernier : une suite déjà présente " + "après ce bloc passe derrière la nouvelle action plutôt que " + "d'être remplacée." + ), + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "after_id": {"type": "string"}, + "action": _LEAF_ACTION_SCHEMA, + }, + "required": ["object_id", "after_id", "action"], + }, + }, + { + "name": "append_action_to_screen_trigger", + "description": ( + "Équivalent d'append_action_to_trigger pour un déclencheur D'ÉCRAN " + "(voir set_screen_triggers), sur l'écran en cours — after_id peut " + "désigner N'IMPORTE QUEL bloc de la chaîne, pas forcément le " + "dernier." + ), + "input_schema": { + "type": "object", + "properties": { + "after_id": {"type": "string"}, + "action": _LEAF_ACTION_SCHEMA, + }, + "required": ["after_id", "action"], + }, + }, + { + "name": "remove_trigger_action", + "description": ( + "Retire UNE action précise (leaf_id) d'un déclencheur d'OBJET, où " + "qu'elle soit dans l'arbre (feuille directe, maillon d'une chaîne " + '"then", sous-action d\'un "interagir", ou branche d\'une ' + '"condition"). Si c\'était la SEULE action de la règle, la règle ' + 'entière disparaît ; si elle avait une suite "then", celle-ci ' + "prend sa place." + ), + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "leaf_id": {"type": "string"}, + }, + "required": ["object_id", "leaf_id"], + }, + }, + { + "name": "remove_screen_trigger_action", + "description": "Équivalent de remove_trigger_action pour un déclencheur D'ÉCRAN.", + "input_schema": { + "type": "object", + "properties": {"leaf_id": {"type": "string"}}, + "required": ["leaf_id"], + }, + }, + { + "name": "move_trigger_action", + "description": ( + "Déplace une action précise (leaf_id) d'un cran vers le haut " + '("up") ou le bas ("down") DANS SA PROPRE CHAÎNE "then", pour ' + "un déclencheur d'OBJET — jamais au-delà de sa branche de " + "condition/sous-action interagir." + ), + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "leaf_id": {"type": "string"}, + "direction": {"type": "string", "enum": ["up", "down"]}, + }, + "required": ["object_id", "leaf_id", "direction"], + }, + }, + { + "name": "move_screen_trigger_action", + "description": "Équivalent de move_trigger_action pour un déclencheur D'ÉCRAN.", + "input_schema": { + "type": "object", + "properties": { + "leaf_id": {"type": "string"}, + "direction": {"type": "string", "enum": ["up", "down"]}, + }, + "required": ["leaf_id", "direction"], + }, + }, +] + + +def _clamp_to_camera( + slug: str, screen_id: int, kind: str | None, x: float, y: float, width: float, height: float +) -> tuple[float, float, str | None]: + """Le placement précis par coordonnées s'est montré peu fiable pour + l'IA malgré des instructions explicites ET un avertissement dans le + résultat de l'outil (bug observé à répétition — l'IA n'arrivait + toujours pas à replacer l'objet correctement, épuisant même parfois + tout le budget d'itérations sans jamais y arriver). Plutôt que de + compter sur elle pour se corriger, le moteur RAMÈNE automatiquement + tout objet (hors "fond", volontairement plus grand que la caméra — + voir screens.resolve_scene_world_size) à l'intérieur du cadre visible + dès cet appel : garantit un résultat correct du premier coup, sans + aller-retour. Renvoie (x, y, note) — note est None si aucun + ajustement n'était nécessaire.""" + if kind == "fond": + return x, y, None + screen = screens.get_screen(slug, screen_id) + if not screen: + return x, y, None + scene_width, scene_height = screen["scene_width"], screen["scene_height"] + clamped_x = min(max(x, 0), max(0, scene_width - width)) + clamped_y = min(max(y, 0), max(0, scene_height - height)) + if (clamped_x, clamped_y) == (x, y): + return x, y, None + note = ( + f"Position ajustée automatiquement de ({int(x)},{int(y)}) à " + f"({int(clamped_x)},{int(clamped_y)}) pour rester dans le cadre visible par la " + f"caméra (0,0)-({scene_width},{scene_height})." + ) + return clamped_x, clamped_y, note + + +def _dispatch_add_scene_object( + slug: str, + screen_id: int, + user_id: int, + kind: str, + forge_character: str | None = None, + background_slug: str | None = None, + image_url: str | None = None, +) -> dict[str, Any]: + # Même garde que la galerie manuelle (core/sprite_gate.py) — un + # compte non-admin ne doit pas pouvoir poser un sprite/fond + # admin-only via l'IA alors que la galerie de l'éditeur ne les lui + # propose déjà pas. + user = auth.get_user_by_id(user_id) + is_admin = bool(user and user["role"] == "admin") + if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and not is_admin: + forge_character = None + if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and not is_admin: + background_slug = None + # screens.add_scene_object ignore SILENCIEUSEMENT un slug invalide + # (comportement voulu pour l'éditeur manuel, où un select HTML ne + # peut de toute façon proposer qu'un slug valide) — mais Claude, lui, + # peut inventer/mal orthographier une valeur malgré l'enum du schéma + # (pas de strict:true ici, voir ai/chat.py). Sans ce contrôle, + # l'outil "réussissait" en silence et Ruby annonçait un fond/ + # personnage posé qui n'apparaissait jamais (bug corrigé). Lever une + # erreur ici la remonte comme résultat d'outil (voir ai/chat.py) : + # Claude la VOIT et peut se corriger dans le même tour. + if forge_character and forge_character not in screens.SPRITE_LIBRARY: + raise ValueError(f"forge_character inconnu : {forge_character!r} (voir la liste enum du tool)") + if background_slug and background_slug not in screens.BACKGROUND_LIBRARY: + raise ValueError(f"background_slug inconnu : {background_slug!r} (voir la liste enum du tool)") + object_id = screens.add_scene_object( + slug, + screen_id, + kind=kind, + forge_character=forge_character, + background_slug=background_slug, + image_url=image_url, + ) + obj = screens.get_scene_object(slug, object_id) + assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction + "object_id vient d'etre cree par screens.add_scene_object juste au-dessus" + ) + # Position par défaut (100,100, voir ensure_scene_schema.py) déjà + # posée par screens.add_scene_object — reste passée par le même + # garde-fou pour rester correcte même si ce défaut changeait un jour. + clamped_x, clamped_y, note = _clamp_to_camera( + slug, screen_id, kind, obj["x"], obj["y"], obj["width"], obj["height"] + ) + if note: + screens.update_scene_object_geometry(slug, object_id, clamped_x, clamped_y, obj["width"], obj["height"]) + result: dict[str, Any] = {"object_id": object_id} + if note: + result["note"] = note + return result + + +def _dispatch_set_object_geometry( + slug: str, screen_id: int, user_id: int, object_id: int, x: float, y: float, width: float, height: float +) -> dict[str, Any]: + obj = screens.get_scene_object(slug, object_id) + x, y, note = _clamp_to_camera(slug, screen_id, obj["kind"] if obj else None, x, y, width, height) + screens.update_scene_object_geometry(slug, object_id, x, y, width, height) + result: dict[str, Any] = {"ok": True} + if note: + result["note"] = note + return result + + +def _dispatch_set_object_name(slug: str, screen_id: int, user_id: int, object_id: int, name: str) -> dict[str, Any]: + screens.set_scene_object_name(slug, object_id, name) + return {"ok": True} + + +def _dispatch_set_object_role(slug: str, screen_id: int, user_id: int, object_id: int, role: str) -> dict[str, Any]: + screens.set_scene_object_role(slug, object_id, role) + return {"ok": True} + + +def _dispatch_set_object_collision( + slug: str, + screen_id: int, + user_id: int, + object_id: int, + enabled: bool = True, + shape: str = "rectangle", + width: float | None = None, + height: float | None = None, + offset_x: float = 0, + offset_y: float = 0, +) -> dict[str, Any]: + # Même forme que routes/scenes/scene_object_collision.py (remplacement + # complet des réglages, jamais un merge partiel). + settings: dict[str, Any] = { + "enabled": bool(enabled), + "shape": shape if shape in _COLLISION_SHAPES else "rectangle", + "width": width, + "height": height, + "offset_x": offset_x or 0, + "offset_y": offset_y or 0, + } + screens.set_scene_object_collision(slug, object_id, settings) + return {"ok": True} + + +def _dispatch_set_quiz_box_config( + slug: str, + screen_id: int, + user_id: int, + object_id: int, + fullscreen: bool = False, + timer_mode: str = "aucun", + timer_seconds: int = 20, + dialog_template: str = "defaut", + page_template: str = "classique", +) -> dict[str, Any]: + # Même forme que screens.set_scene_object_quiz_config (remplacement + # complet des réglages, jamais un merge partiel) — screens.py sanitize + # de toute façon toute valeur hors énumération, ce garde-fou ici sert + # juste à ne jamais transmettre None à sanitize_quiz_box_config. + # dialog_template/page_template sont conservés INDÉPENDAMMENT (voir + # screens/rendering/quiz_box_config.py) : Ruby peut en régler un seul + # sans jamais écraser l'autre. + config: dict[str, Any] = { + "fullscreen": bool(fullscreen), + "timer_mode": timer_mode if timer_mode in _QUIZ_BOX_TIMER_MODES else "aucun", + "timer_seconds": timer_seconds or 20, + "dialog_template": dialog_template if dialog_template in _QUIZ_BOX_DIALOG_TEMPLATES else "defaut", + "page_template": page_template if page_template in _QUIZ_BOX_PAGE_TEMPLATES else "classique", + } + screens.set_scene_object_quiz_config(slug, object_id, config) + return {"ok": True} + + +def _dispatch_create_global_variable( + slug: str, + screen_id: int, + user_id: int, + name: str, + var_type: str = "texte", + default_value: str = "", + per_player: bool = True, +) -> dict[str, Any]: + variable_id = db.create_global_variable( + slug, name, var_type=var_type, default_value=default_value, per_player=per_player + ) + return {"variable_id": variable_id} + + +def _dispatch_set_collision_rules( + slug: str, screen_id: int, user_id: int, object_id: int, rules: Any +) -> dict[str, Any]: + sanitized = screens.sanitize_collision_rules(rules) + screens.set_scene_object_collision_rules(slug, object_id, sanitized) + return {"ok": True, "rules": sanitized} + + +def _dispatch_append_action_to_trigger( + slug: str, screen_id: int, user_id: int, object_id: int, after_id: str, action: dict[str, Any] +) -> dict[str, Any]: + ok = screens.append_action_to_trigger(slug, object_id, after_id, action) + return {"ok": ok} + + +def _dispatch_set_screen_triggers(slug: str, screen_id: int, user_id: int, rules: Any) -> dict[str, Any]: + sanitized = screens.sanitize_screen_triggers(rules) + screens.set_screen_triggers(slug, screen_id, sanitized) + return {"ok": True, "rules": sanitized} + + +def _dispatch_append_action_to_screen_trigger( + slug: str, screen_id: int, user_id: int, after_id: str, action: dict[str, Any] +) -> dict[str, Any]: + ok = screens.append_action_to_screen_trigger(slug, screen_id, after_id, action) + return {"ok": ok} + + +def _dispatch_remove_trigger_action( + slug: str, screen_id: int, user_id: int, object_id: int, leaf_id: str +) -> dict[str, Any]: + ok = screens.remove_action_from_trigger(slug, object_id, leaf_id) + return {"ok": ok} + + +def _dispatch_remove_screen_trigger_action(slug: str, screen_id: int, user_id: int, leaf_id: str) -> dict[str, Any]: + ok = screens.remove_action_from_screen_trigger(slug, screen_id, leaf_id) + return {"ok": ok} + + +def _dispatch_move_trigger_action( + slug: str, screen_id: int, user_id: int, object_id: int, leaf_id: str, direction: str +) -> dict[str, Any]: + ok = screens.move_action_in_trigger(slug, object_id, leaf_id, direction) + return {"ok": ok} + + +def _dispatch_move_screen_trigger_action( + slug: str, screen_id: int, user_id: int, leaf_id: str, direction: str +) -> dict[str, Any]: + ok = screens.move_action_in_screen_trigger(slug, screen_id, leaf_id, direction) + return {"ok": ok} + + +def _dispatch_add_generated_image(slug: str, screen_id: int, user_id: int, kind: str, prompt: str) -> dict[str, Any]: + """Scenario -> "Mes assets" (auth.save_user_asset, source="ia") -> + posée sur l'écran via LE MÊME chemin que Phase 1 + (screens.add_scene_object(image_url=...)) — jamais un chemin + parallèle. Toute erreur (Scenario mal configuré, timeout, échec) + remonte comme un résultat d'outil normal, voir ai/chat.py.""" + image_url = generate_image_url(prompt) + downloaded = requests.get(image_url, timeout=60) + downloaded.raise_for_status() + ext = os.path.splitext(image_url.split("?")[0])[1] or ".png" + asset_id, filename = auth.save_user_asset(user_id, downloaded.content, ext, original_name=prompt[:80], source="ia") + served_url = url_for("serve_user_asset", user_id=user_id, filename=filename) + object_id = screens.add_scene_object(slug, screen_id, kind=kind, image_url=served_url) + return {"object_id": object_id, "asset_id": asset_id} + + +_DISPATCH: dict[str, Callable[..., dict[str, Any]]] = { + "add_scene_object": _dispatch_add_scene_object, + "set_object_geometry": _dispatch_set_object_geometry, + "set_object_name": _dispatch_set_object_name, + "set_object_role": _dispatch_set_object_role, + "set_object_collision": _dispatch_set_object_collision, + "set_quiz_box_config": _dispatch_set_quiz_box_config, + "create_global_variable": _dispatch_create_global_variable, + "set_collision_rules": _dispatch_set_collision_rules, + "append_action_to_trigger": _dispatch_append_action_to_trigger, + "set_screen_triggers": _dispatch_set_screen_triggers, + "append_action_to_screen_trigger": _dispatch_append_action_to_screen_trigger, + "remove_trigger_action": _dispatch_remove_trigger_action, + "remove_screen_trigger_action": _dispatch_remove_screen_trigger_action, + "move_trigger_action": _dispatch_move_trigger_action, + "move_screen_trigger_action": _dispatch_move_screen_trigger_action, + "add_generated_image": _dispatch_add_generated_image, +} + + +def dispatch_tool( + slug: str, screen_id: int, user_id: int, tool_name: str, tool_input: dict[str, Any] +) -> dict[str, Any]: + """Point d'entrée UNIQUE utilisé par la boucle tool-use (Phase 2) — + lève KeyError pour un nom d'outil inconnu (jamais silencieux : un tool + annoncé par TOOLS mais absent d'ici serait un bug de ce module, pas + une entrée utilisateur à tolérer). `user_id` : nécessaire pour "Mes + assets" (scopé par compte, voir add_generated_image ci-dessus) — + ignoré par les autres outils, qui n'agissent que sur l'écran/l'objet.""" + handler = _DISPATCH[tool_name] + return handler(slug, screen_id, user_id, **tool_input) diff --git a/app.py b/app.py index c7322838..b04a1d3c 100644 --- a/app.py +++ b/app.py @@ -15,19 +15,23 @@ Lance un serveur web local. Fonctionnalités construites pour l'instant une relation existante. """ +import os import threading import webbrowser -from core.flask_app import app -from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja import routes # noqa: F401 - enregistre toutes les routes sur `app` -from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes) -from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja -from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF -from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja +from core import ( + auth_guard, # noqa: F401 - enregistre la garde de connexion (après les routes) + csrf, # noqa: F401 - enregistre csrf_token() comme variable globale Jinja + csrf_guard, # noqa: F401 - enregistre la vérification du jeton CSRF + db_teardown_guard, # noqa: F401 - enregistre la fermeture des connexions SQLite fuitées + jinja_filters, # noqa: F401 - enregistre les filtres Jinja + recovery_codes_flash, # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja +) +from core.flask_app import app -def _open_browser(): +def _open_browser() -> None: webbrowser.open("http://127.0.0.1:5050/") @@ -41,4 +45,9 @@ if __name__ == "__main__": # l'éditeur de scène "vide" le temps que les images finissent par # arriver l'une après l'autre — un rechargement normal, servi surtout # depuis le cache navigateur, le cachait). - app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False, threaded=True) + # debug : jamais actif par defaut (Bandit B201 - le debogueur Werkzeug + # permet l'execution de code arbitraire) — activable en local via + # FORGE_DEBUG=1 dans .env, jamais utilise en production (gunicorn y + # sert app:app directement, ce bloc __main__ n'y tourne pas). + debug = os.environ.get("FORGE_DEBUG") == "1" + app.run(host="127.0.0.1", port=5050, debug=debug, use_reloader=False, threaded=True) diff --git a/auth/__init__.py b/auth/__init__.py index c869ba36..3630b741 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -4,46 +4,94 @@ create_user.py pour le détail des règles. Base SQLite entièrement séparée de db/ (une base par JEU) : ces comptes n'appartiennent à aucun jeu, ils en POSSÈDENT un (project_slug).""" +from .confirm_totp import confirm_totp from .connection import users_db_path +from .count_admins import count_admins +from .create_user import UserCreationError, create_user +from .create_user_asset import create_user_asset +from .delete_user import delete_user +from .delete_user_asset import delete_user_asset from .ensure_schema import ensure_users_schema -from .is_first_user import is_first_user -from .create_user import create_user, UserCreationError +from .ensure_user_assets_schema import ensure_user_assets_schema +from .get_user_asset import get_user_asset from .get_user_by_email import get_user_by_email from .get_user_by_id import get_user_by_id -from .verify_password import verify_password -from .confirm_totp import confirm_totp -from .verify_totp import verify_totp -from .set_project_slug import set_project_slug -from .password_strength import password_strength, MIN_SCORE_REQUIRED -from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg -from .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_message -from .record_failed_attempt import record_failed_attempt -from .reset_failed_attempts import reset_failed_attempts -from .recovery_codes import generate_recovery_codes, verify_recovery_code -from .set_password import set_password +from .image_dimensions import image_dimensions +from .is_first_user import is_first_user +from .list_user_assets import list_user_assets from .password_reset import ( - create_password_reset_token, get_user_id_for_valid_token, consume_password_reset_token, TOKEN_TTL_MINUTES, + consume_password_reset_token, + create_password_reset_token, + get_user_id_for_valid_token, ) -from .send_email import send_password_reset_email, EmailNotConfiguredError +from .password_strength import MIN_SCORE_REQUIRED, password_strength +from .rate_limit import lockout_message, lockout_minutes_for, seconds_locked_remaining +from .record_failed_attempt import record_failed_attempt +from .recovery_codes import generate_recovery_codes, verify_recovery_code +from .reset_failed_attempts import reset_failed_attempts +from .save_user_asset import save_user_asset +from .send_email import EmailNotConfiguredError, send_password_reset_email +from .set_password import set_password +from .set_project_slug import set_project_slug +from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg +from .update_email import EmailUpdateError, update_email from .update_profile import update_profile -from .count_admins import count_admins -from .delete_user import delete_user -from .update_email import update_email, EmailUpdateError +from .update_user_asset_scene_kind import update_user_asset_scene_kind +from .user_asset_kind import user_asset_kind +from .user_assets_dir import user_assets_dir +from .validate_audio_duration import MAX_AUDIO_SECONDS, validate_audio_duration +from .validate_video_duration import MAX_VIDEO_SECONDS, validate_video_duration +from .verify_password import verify_password +from .verify_totp import verify_totp __all__ = [ - "users_db_path", "ensure_users_schema", "is_first_user", - "create_user", "UserCreationError", - "get_user_by_email", "get_user_by_id", - "verify_password", "confirm_totp", "verify_totp", "set_project_slug", - "password_strength", "MIN_SCORE_REQUIRED", - "totp_provisioning_uri", "totp_qrcode_svg", - "lockout_minutes_for", "seconds_locked_remaining", "lockout_message", - "record_failed_attempt", "reset_failed_attempts", - "generate_recovery_codes", "verify_recovery_code", - "set_password", "create_password_reset_token", "get_user_id_for_valid_token", - "consume_password_reset_token", "TOKEN_TTL_MINUTES", - "send_password_reset_email", "EmailNotConfiguredError", - "update_profile", "count_admins", "delete_user", - "update_email", "EmailUpdateError", + "users_db_path", + "ensure_users_schema", + "is_first_user", + "create_user", + "UserCreationError", + "get_user_by_email", + "get_user_by_id", + "verify_password", + "confirm_totp", + "verify_totp", + "set_project_slug", + "password_strength", + "MIN_SCORE_REQUIRED", + "totp_provisioning_uri", + "totp_qrcode_svg", + "lockout_minutes_for", + "seconds_locked_remaining", + "lockout_message", + "record_failed_attempt", + "reset_failed_attempts", + "generate_recovery_codes", + "verify_recovery_code", + "set_password", + "create_password_reset_token", + "get_user_id_for_valid_token", + "consume_password_reset_token", + "TOKEN_TTL_MINUTES", + "send_password_reset_email", + "EmailNotConfiguredError", + "update_profile", + "count_admins", + "delete_user", + "update_email", + "EmailUpdateError", + "ensure_user_assets_schema", + "user_assets_dir", + "create_user_asset", + "list_user_assets", + "get_user_asset", + "delete_user_asset", + "save_user_asset", + "user_asset_kind", + "validate_audio_duration", + "MAX_AUDIO_SECONDS", + "validate_video_duration", + "MAX_VIDEO_SECONDS", + "image_dimensions", + "update_user_asset_scene_kind", ] diff --git a/auth/confirm_totp.py b/auth/confirm_totp.py index 7d91b14a..c7448990 100644 --- a/auth/confirm_totp.py +++ b/auth/confirm_totp.py @@ -1,7 +1,7 @@ from .connection import connect -def confirm_totp(user_id): +def confirm_totp(user_id: int) -> None: conn = connect() conn.execute("UPDATE _users SET totp_confirmed = 1 WHERE id = ?", (user_id,)) conn.commit() diff --git a/auth/connection.py b/auth/connection.py index 4b5ee4f0..8275804d 100644 --- a/auth/connection.py +++ b/auth/connection.py @@ -8,6 +8,7 @@ tests/conftest.py positionne FORGE_USERS_DB_PATH vers un fichier temporaire avant de faire tourner la suite, pour ne jamais toucher à la vraie base de comptes (data/users.db) ni dépendre d'un état "premier compte = admin" déjà consommé par un run précédent.""" + import os import sqlite3 @@ -15,11 +16,11 @@ _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_USERS_DB_PATH = os.path.join(_BASE_DIR, "data", "users.db") -def users_db_path(): +def users_db_path() -> str: return os.environ.get("FORGE_USERS_DB_PATH") or DEFAULT_USERS_DB_PATH -def connect(): +def connect() -> sqlite3.Connection: path = users_db_path() os.makedirs(os.path.dirname(path), exist_ok=True) conn = sqlite3.connect(path, timeout=10) diff --git a/auth/count_admins.py b/auth/count_admins.py index 43647438..80bd60ae 100644 --- a/auth/count_admins.py +++ b/auth/count_admins.py @@ -1,8 +1,8 @@ from .connection import connect -def count_admins(): +def count_admins() -> int: conn = connect() - n = conn.execute("SELECT COUNT(*) AS n FROM _users WHERE role = 'admin'").fetchone()["n"] + n = int(conn.execute("SELECT COUNT(*) AS n FROM _users WHERE role = 'admin'").fetchone()["n"]) conn.close() return n diff --git a/auth/create_user.py b/auth/create_user.py index 3016e39b..0bd91f7d 100644 --- a/auth/create_user.py +++ b/auth/create_user.py @@ -1,9 +1,9 @@ import pyotp from werkzeug.security import generate_password_hash -from .ensure_schema import ensure_users_schema from .connection import connect from .email_validation import is_valid_email +from .ensure_schema import ensure_users_schema from .get_user_by_email import get_user_by_email from .is_first_user import is_first_user from .password_strength import password_strength @@ -14,7 +14,7 @@ class UserCreationError(Exception): dans le formulaire d'inscription) — jamais un détail SQL/technique.""" -def create_user(email, password, nom, prenom): +def create_user(email: str | None, password: str | None, nom: str | None, prenom: str | None) -> int: """Crée un compte : mot de passe fort (auth/password_strength.py) et 2FA (TOTP) rendus obligatoires — le secret est généré ici mais totp_confirmed reste à 0 tant que confirm_totp() n'a pas vérifié un @@ -26,6 +26,7 @@ def create_user(email, password, nom, prenom): email = (email or "").strip().lower() nom = (nom or "").strip() prenom = (prenom or "").strip() + password = password or "" if not is_valid_email(email): raise UserCreationError("Adresse email invalide.") if not nom or not prenom: @@ -44,7 +45,7 @@ def create_user(email, password, nom, prenom): VALUES (?, ?, ?, ?, ?, ?, 0)""", (email, generate_password_hash(password), nom, prenom, role, totp_secret), ) - user_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + user_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]) conn.commit() conn.close() return user_id diff --git a/auth/create_user_asset.py b/auth/create_user_asset.py new file mode 100644 index 00000000..6c0054de --- /dev/null +++ b/auth/create_user_asset.py @@ -0,0 +1,33 @@ +from .connection import connect +from .ensure_user_assets_schema import ensure_user_assets_schema + + +def create_user_asset( + user_id: int, + filename: str, + original_name: str | None = None, + source: str = "upload", + scene_kind: str | None = None, +) -> int: + """Enregistre une ligne "Mes assets" pour un fichier déjà écrit sur + disque (voir user_assets_dir.py) — le fichier lui-même est écrit par + l'appelant (route d'upload, ou plus tard la génération IA), cette + fonction ne fait que la partie base de données, comme + db.create_global_variable pour le reste du moteur. + + scene_kind ("decor"/"fond", images uniquement — voir + routes/scenes/scene_edit_view.py) : décide comment un CLIC sur la + tuile pose l'objet (screens/scenes/add_scene_object.py), demande + explicite "un bouton d'import séparé pour les images de fond et les + objets" — sans distinction, toute image "Mes assets" ne pouvait être + posée qu'en "decor", jamais comme fond.""" + ensure_user_assets_schema() + conn = connect() + conn.execute( + "INSERT INTO _user_assets (user_id, filename, original_name, source, scene_kind) VALUES (?, ?, ?, ?, ?)", + (user_id, filename, original_name, source, scene_kind), + ) + asset_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]) + conn.commit() + conn.close() + return asset_id diff --git a/auth/delete_user.py b/auth/delete_user.py index 85801ea4..3d9cad61 100644 --- a/auth/delete_user.py +++ b/auth/delete_user.py @@ -1,7 +1,7 @@ from .connection import connect -def delete_user(user_id): +def delete_user(user_id: int) -> None: """Supprime le compte et tout ce qui lui est directement rattaché (codes de récupération, jetons de réinitialisation encore en cours) — le dossier de projet, lui, est géré par l'appelant (voir diff --git a/auth/delete_user_asset.py b/auth/delete_user_asset.py new file mode 100644 index 00000000..df50e6d9 --- /dev/null +++ b/auth/delete_user_asset.py @@ -0,0 +1,25 @@ +import os + +from .connection import connect +from .ensure_user_assets_schema import ensure_user_assets_schema +from .user_assets_dir import user_assets_dir + + +def delete_user_asset(asset_id: int, user_id: int) -> bool: + """N'efface que si asset_id APPARTIENT à user_id (jamais fournie par + l'appelant sans vérification — voir get_user_asset.py) : renvoie False + sans rien faire sinon, plutôt que de lever une erreur, même patron que + screens.delete_scene_object pour un id introuvable.""" + ensure_user_assets_schema() + conn = connect() + row = conn.execute("SELECT filename FROM _user_assets WHERE id = ? AND user_id = ?", (asset_id, user_id)).fetchone() + if not row: + conn.close() + return False + conn.execute("DELETE FROM _user_assets WHERE id = ?", (asset_id,)) + conn.commit() + conn.close() + path = os.path.join(user_assets_dir(user_id), row["filename"]) + if os.path.exists(path): + os.remove(path) + return True diff --git a/auth/email_validation.py b/auth/email_validation.py index 48afcbfd..bd416b53 100644 --- a/auth/email_validation.py +++ b/auth/email_validation.py @@ -3,5 +3,5 @@ import re EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") -def is_valid_email(email): +def is_valid_email(email: str | None) -> bool: return bool(EMAIL_RE.match((email or "").strip())) diff --git a/auth/ensure_schema.py b/auth/ensure_schema.py index 9d631891..348a6f44 100644 --- a/auth/ensure_schema.py +++ b/auth/ensure_schema.py @@ -1,7 +1,7 @@ from .connection import connect -def ensure_users_schema(): +def ensure_users_schema() -> None: conn = connect() conn.executescript( """ diff --git a/auth/ensure_user_assets_schema.py b/auth/ensure_user_assets_schema.py new file mode 100644 index 00000000..ae5bacd8 --- /dev/null +++ b/auth/ensure_user_assets_schema.py @@ -0,0 +1,34 @@ +from .connection import connect + + +def ensure_user_assets_schema() -> None: + """ "Mes assets" (voir user_assets_dir.py) : une image appartient à un + COMPTE, jamais à un projet — table dans la base de comptes partagée + (auth/connection.py), pas dans le game.db d'un jeu (db/connection.py), + pour rester utilisable d'un projet à l'autre du même compte.""" + conn = connect() + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS _user_assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + filename TEXT NOT NULL, + original_name TEXT, + source TEXT NOT NULL DEFAULT 'upload' CHECK (source IN ('upload', 'ia')), + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + asset_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_user_assets)").fetchall()} + if "scene_kind" not in asset_cols: + # Demande explicite : "un bouton d'import séparé pour les images + # de fond et les objets" — une image "Mes assets" est posée sur + # la scène TOUJOURS en kind="decor" jusqu'ici (voir + # screens/scenes/add_scene_object.py), impossible à utiliser comme + # "fond" (position/empilement différents, voir add_scene_object.py). + # NULL pour un son/une vidéo (n'a pas de sens) OU une image + # importée AVANT ce réglage (retombe sur "decor", comportement + # inchangé — voir routes/scenes/scene_edit_view.py). + conn.execute("ALTER TABLE _user_assets ADD COLUMN scene_kind TEXT") + conn.commit() + conn.close() diff --git a/auth/get_user_asset.py b/auth/get_user_asset.py new file mode 100644 index 00000000..a777dd10 --- /dev/null +++ b/auth/get_user_asset.py @@ -0,0 +1,19 @@ +from typing import Any + +from .connection import connect +from .ensure_user_assets_schema import ensure_user_assets_schema + + +def get_user_asset(asset_id: int) -> dict[str, Any] | None: + """Renvoie aussi user_id — indispensable pour vérifier la PROPRIÉTÉ + avant de servir/poser un asset (voir routes/assets/serve_user_asset.py, + routes/scenes/scene_object_add.py), même esprit que l'isolation par + propriétaire déjà en place sur les projets (core/auth_guard.py).""" + ensure_user_assets_schema() + conn = connect() + row = conn.execute( + "SELECT id, user_id, filename, original_name, source, created_at FROM _user_assets WHERE id = ?", + (asset_id,), + ).fetchone() + conn.close() + return dict(row) if row else None diff --git a/auth/get_user_by_email.py b/auth/get_user_by_email.py index c7519875..586f2614 100644 --- a/auth/get_user_by_email.py +++ b/auth/get_user_by_email.py @@ -1,8 +1,10 @@ -from .ensure_schema import ensure_users_schema +from typing import Any + from .connection import connect +from .ensure_schema import ensure_users_schema -def get_user_by_email(email): +def get_user_by_email(email: str | None) -> dict[str, Any] | None: ensure_users_schema() conn = connect() row = conn.execute("SELECT * FROM _users WHERE email = ?", ((email or "").strip().lower(),)).fetchone() diff --git a/auth/get_user_by_id.py b/auth/get_user_by_id.py index f95014c6..6e86ef3e 100644 --- a/auth/get_user_by_id.py +++ b/auth/get_user_by_id.py @@ -1,8 +1,10 @@ -from .ensure_schema import ensure_users_schema +from typing import Any + from .connection import connect +from .ensure_schema import ensure_users_schema -def get_user_by_id(user_id): +def get_user_by_id(user_id: int | None) -> dict[str, Any] | None: ensure_users_schema() if not user_id: return None diff --git a/auth/image_dimensions.py b/auth/image_dimensions.py new file mode 100644 index 00000000..01072e40 --- /dev/null +++ b/auth/image_dimensions.py @@ -0,0 +1,70 @@ +import struct + + +def image_dimensions(content: bytes) -> tuple[int, int] | None: + """Largeur/hauteur RÉELLES (en pixels) d'une image, lues directement + dans ses en-têtes — SANS dépendance externe (Pillow serait la solution + habituelle, mais un simple parsing des formats courants suffit ici et + évite d'ajouter une dépendance binaire lourde juste pour ça). Renvoie + None si le format n'est pas reconnu (ex. SVG vectoriel sans taille + fixe) — l'appelant retombe alors sur les valeurs par défaut du schéma + (voir screens/scenes/add_scene_object.py).""" + if content[:8] == b"\x89PNG\r\n\x1a\n": + if len(content) >= 24: + width, height = struct.unpack(">II", content[16:24]) + return width, height + return None + if content[:6] in (b"GIF87a", b"GIF89a"): + if len(content) >= 10: + width, height = struct.unpack("= 26: + width, height = struct.unpack(" tuple[int, int] | None: + chunk = content[12:16] + if chunk == b"VP8X" and len(content) >= 30: + width = 1 + (content[24] | (content[25] << 8) | (content[26] << 16)) + height = 1 + (content[27] | (content[28] << 8) | (content[29] << 16)) + return width, height + if chunk == b"VP8 " and len(content) >= 30: + width, height = struct.unpack("= 25: + b = content[21:25] + bits = b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24) + width = (bits & 0x3FFF) + 1 + height = ((bits >> 14) & 0x3FFF) + 1 + return width, height + return None + + +def _jpeg_dimensions(content: bytes) -> tuple[int, int] | None: + i = 2 + n = len(content) + while i + 9 < n: + if content[i] != 0xFF: + i += 1 + continue + marker = content[i + 1] + if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7: + i += 2 + continue + if marker == 0xD9: + break + seg_len = struct.unpack(">H", content[i + 2 : i + 4])[0] + if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): + height, width = struct.unpack(">HH", content[i + 5 : i + 9]) + return width, height + i += 2 + seg_len + return None diff --git a/auth/is_first_user.py b/auth/is_first_user.py index cc73ef6d..6f52e931 100644 --- a/auth/is_first_user.py +++ b/auth/is_first_user.py @@ -1,8 +1,8 @@ -from .ensure_schema import ensure_users_schema from .connection import connect +from .ensure_schema import ensure_users_schema -def is_first_user(): +def is_first_user() -> bool: """True s'il n'existe encore AUCUN compte — le tout premier compte créé devient automatiquement admin (voir create_user.py), pour ne jamais avoir besoin d'un mot de passe par défaut ou d'un script de bootstrap @@ -11,4 +11,4 @@ def is_first_user(): conn = connect() count = conn.execute("SELECT COUNT(*) AS c FROM _users").fetchone()["c"] conn.close() - return count == 0 + return bool(count == 0) diff --git a/auth/list_user_assets.py b/auth/list_user_assets.py new file mode 100644 index 00000000..3de37b74 --- /dev/null +++ b/auth/list_user_assets.py @@ -0,0 +1,19 @@ +from typing import Any + +from .connection import connect +from .ensure_user_assets_schema import ensure_user_assets_schema + + +def list_user_assets(user_id: int) -> list[dict[str, Any]]: + """ "Mes assets" d'un compte, plus récent en premier — voir + templates/scene_edit.html, bloc "Mes assets" du panneau d'ajout + d'élément.""" + ensure_user_assets_schema() + conn = connect() + rows = conn.execute( + "SELECT id, user_id, filename, original_name, source, scene_kind, created_at " + "FROM _user_assets WHERE user_id = ? ORDER BY id DESC", + (user_id,), + ).fetchall() + conn.close() + return [dict(row) for row in rows] diff --git a/auth/password_reset.py b/auth/password_reset.py index 7b7c7661..b4d9b273 100644 --- a/auth/password_reset.py +++ b/auth/password_reset.py @@ -7,11 +7,11 @@ from .connection import connect TOKEN_TTL_MINUTES = 60 -def _hash_token(token): +def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() -def create_password_reset_token(user_id): +def create_password_reset_token(user_id: int) -> str: """Un seul jeton valide à la fois par utilisateur : en créer un nouveau invalide silencieusement tout jeu envoyé plus tôt (par exemple si l'utilisateur redemande un email parce que le premier @@ -29,7 +29,7 @@ def create_password_reset_token(user_id): return token -def get_user_id_for_valid_token(token): +def get_user_id_for_valid_token(token: str | None) -> int | None: if not token: return None conn = connect() @@ -42,10 +42,10 @@ def get_user_id_for_valid_token(token): return None if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): return None - return row["user_id"] + return int(row["user_id"]) -def consume_password_reset_token(token): +def consume_password_reset_token(token: str) -> None: conn = connect() conn.execute( "UPDATE _password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?", diff --git a/auth/password_strength.py b/auth/password_strength.py index 6ea9c3af..9b27a7a2 100644 --- a/auth/password_strength.py +++ b/auth/password_strength.py @@ -1,11 +1,12 @@ import re +from typing import Any, Callable # Mêmes règles des deux côtés (ici pour le refus serveur, en JS dans # templates/auth/register.html pour le schéma visuel qui guide la saisie # en temps réel) — un mot de passe REFUSÉ côté serveur doit toujours # correspondre à une jauge/coche déjà rouge côté client, jamais une # surprise après coup. -_CHECKS = [ +_CHECKS: list[tuple[str, str, Callable[[str], bool]]] = [ ("longueur", "Au moins 8 caractères", lambda p: len(p) >= 8), ("majuscule", "Une majuscule", lambda p: re.search(r"[A-Z]", p) is not None), ("minuscule", "Une minuscule", lambda p: re.search(r"[a-z]", p) is not None), @@ -19,7 +20,7 @@ _CHECKS = [ MIN_SCORE_REQUIRED = 4 -def password_strength(password): +def password_strength(password: str | None) -> dict[str, Any]: """Renvoie {"score": 0-5, "checks": [{"key","label","ok"}, ...], "valid": bool} — jamais None, un mot de passe vide obtient juste un score de 0 (toutes les règles échouent), pas une erreur.""" diff --git a/auth/rate_limit.py b/auth/rate_limit.py index 2467ef86..66797da8 100644 --- a/auth/rate_limit.py +++ b/auth/rate_limit.py @@ -3,24 +3,26 @@ que soit l'étape attaquée) : 3 essais libres, puis un temps d'attente qui double à chaque échec supplémentaire (5 min, 10, 20, 40...), plafonné à 1h. Remis à zéro dès une connexion réussie (voir reset_failed_attempts).""" + import math -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone +from typing import Any _FREE_ATTEMPTS = 3 _FIRST_LOCKOUT_MINUTES = 5 _MAX_LOCKOUT_MINUTES = 60 -def lockout_minutes_for(failed_attempts): +def lockout_minutes_for(failed_attempts: int) -> int: """0 tant qu'on est dans les 3 essais libres ; sinon 5 * 2^n, plafonné à 60 — jamais négatif, jamais None.""" if failed_attempts <= _FREE_ATTEMPTS: return 0 exponent = failed_attempts - _FREE_ATTEMPTS - 1 - return min(_FIRST_LOCKOUT_MINUTES * (2 ** exponent), _MAX_LOCKOUT_MINUTES) + return min(int(_FIRST_LOCKOUT_MINUTES * (2**exponent)), _MAX_LOCKOUT_MINUTES) -def _parse(dt_str): +def _parse(dt_str: str | None) -> datetime | None: if not dt_str: return None try: @@ -29,7 +31,7 @@ def _parse(dt_str): return None -def lockout_message(seconds_remaining): +def lockout_message(seconds_remaining: float) -> str: """Message affiché tel quel dans le formulaire — arrondi à la minute supérieure (jamais "0 minute" alors qu'il en reste un peu, jamais un compte de secondes qui oblige à recharger sans arrêt pour voir si @@ -39,7 +41,7 @@ def lockout_message(seconds_remaining): return f"Trop de tentatives. Réessaie dans {minutes} {unit}." -def seconds_locked_remaining(user): +def seconds_locked_remaining(user: dict[str, Any] | None) -> int: """> 0 si le compte est actuellement verrouillé (temps restant, en secondes, arrondi au-dessus pour ne jamais afficher "0 minute" alors qu'il en reste réellement un peu) ; 0 sinon.""" diff --git a/auth/record_failed_attempt.py b/auth/record_failed_attempt.py index 46b79ef5..287e44fe 100644 --- a/auth/record_failed_attempt.py +++ b/auth/record_failed_attempt.py @@ -4,7 +4,7 @@ from .connection import connect from .rate_limit import lockout_minutes_for -def record_failed_attempt(user_id): +def record_failed_attempt(user_id: int) -> int: """Incrémente le compteur d'échecs de CE compte et, une fois passé les 3 essais libres, pose/allonge son verrouillage (voir rate_limit.py) — appelé sur un mot de passe OU un code 2FA incorrect, jamais sur un diff --git a/auth/recovery_codes.py b/auth/recovery_codes.py index 7c6b8799..a28ba382 100644 --- a/auth/recovery_codes.py +++ b/auth/recovery_codes.py @@ -7,11 +7,11 @@ from .connection import connect _CODE_COUNT = 10 -def _format_code(raw): - return "-".join(raw[i:i + 4] for i in range(0, len(raw), 4)) +def _format_code(raw: str) -> str: + return "-".join(raw[i : i + 4] for i in range(0, len(raw), 4)) -def generate_recovery_codes(user_id): +def generate_recovery_codes(user_id: int) -> list[str]: """(Re)génère les codes de récupération 2FA d'un utilisateur : un nouvel appel invalide tout jeu de codes précédent (un seul jeu valide à la fois, pour ne jamais avoir à deviner lesquels tiennent encore). @@ -21,7 +21,7 @@ def generate_recovery_codes(user_id): directement réutilisables.""" conn = connect() conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,)) - codes = [] + codes: list[str] = [] for _ in range(_CODE_COUNT): code = _format_code(secrets.token_hex(6)) codes.append(code) @@ -34,7 +34,7 @@ def generate_recovery_codes(user_id): return codes -def verify_recovery_code(user_id, code): +def verify_recovery_code(user_id: int, code: str | None) -> bool: """Un code n'est utilisable qu'UNE SEULE FOIS (used_at) : consommé dès qu'il sert à une connexion réussie, pour qu'un code intercepté une fois (capture d'écran, historique du navigateur...) ne redonne pas un diff --git a/auth/reset_failed_attempts.py b/auth/reset_failed_attempts.py index 057ff22e..0f10a986 100644 --- a/auth/reset_failed_attempts.py +++ b/auth/reset_failed_attempts.py @@ -1,7 +1,7 @@ from .connection import connect -def reset_failed_attempts(user_id): +def reset_failed_attempts(user_id: int) -> None: """Remet le compteur à zéro et lève tout verrouillage — appelé dès qu'une étape de connexion réussit (mot de passe validé ou code 2FA validé), pour ne jamais punir un utilisateur légitime qui s'est juste diff --git a/auth/save_user_asset.py b/auth/save_user_asset.py new file mode 100644 index 00000000..b926964f --- /dev/null +++ b/auth/save_user_asset.py @@ -0,0 +1,27 @@ +import os +import uuid + +from .create_user_asset import create_user_asset +from .user_assets_dir import user_assets_dir + + +def save_user_asset( + user_id: int, + content: bytes, + ext: str, + original_name: str | None = None, + source: str = "upload", + scene_kind: str | None = None, +) -> tuple[int, str]: + """Écrit `content` (bytes) sur disque + crée la ligne "Mes assets" — + factorisé pour être partagé par l'upload manuel + (routes/assets/upload_user_asset.py) ET la génération IA + (ai/tools.py::_dispatch_add_generated_image), jamais dupliqué entre + les deux. Renvoie (asset_id, filename).""" + filename = uuid.uuid4().hex + ext + asset_dir = user_assets_dir(user_id) + os.makedirs(asset_dir, exist_ok=True) + with open(os.path.join(asset_dir, filename), "wb") as f: + f.write(content) + asset_id = create_user_asset(user_id, filename, original_name=original_name, source=source, scene_kind=scene_kind) + return asset_id, filename diff --git a/auth/send_email.py b/auth/send_email.py index 3c9c97ae..44cd3ab6 100644 --- a/auth/send_email.py +++ b/auth/send_email.py @@ -4,6 +4,7 @@ Python. Configuré uniquement par variables d'environnement (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) : ce module ne connaît et ne stocke jamais le mot de passe SMTP en dur, à définir sur le poste/ serveur qui fait tourner l'appli.""" + import os import smtplib from email.mime.text import MIMEText @@ -19,7 +20,7 @@ class EmailNotConfiguredError(Exception): qu'il doit finir de configurer son SMTP.""" -def send_email(to_email, subject, body): +def send_email(to_email: str, subject: str, body: str) -> None: host = os.environ.get("SMTP_HOST") port = int(os.environ.get("SMTP_PORT", "587")) user = os.environ.get("SMTP_USER") @@ -40,7 +41,7 @@ def send_email(to_email, subject, body): server.sendmail(sender, [to_email], msg.as_string()) -def send_password_reset_email(to_email, reset_url): +def send_password_reset_email(to_email: str, reset_url: str) -> None: body = ( "Une réinitialisation de mot de passe a été demandée pour ce compte " "Forge Engine.\n\n" diff --git a/auth/set_password.py b/auth/set_password.py index 88b1cc72..cc3ed640 100644 --- a/auth/set_password.py +++ b/auth/set_password.py @@ -3,7 +3,7 @@ from werkzeug.security import generate_password_hash from .connection import connect -def set_password(user_id, new_password): +def set_password(user_id: int, new_password: str) -> None: """Remet aussi le compteur anti-bruteforce à zéro (auth/rate_limit.py) : prouver son identité par email est une voie de récupération légitime, un compte verrouillé après trop d'échecs ne doit pas rester bloqué une diff --git a/auth/set_project_slug.py b/auth/set_project_slug.py index dcb84fd6..eb42a68f 100644 --- a/auth/set_project_slug.py +++ b/auth/set_project_slug.py @@ -1,7 +1,7 @@ from .connection import connect -def set_project_slug(user_id, slug): +def set_project_slug(user_id: int, slug: str) -> None: """Enregistre le SEUL projet que ce compte possède (voir create_user.py — un compte "user" n'en aura jamais qu'un ; un compte "admin" n'a PAS besoin de cette colonne, il reste libre de créer diff --git a/auth/totp_qrcode_svg.py b/auth/totp_qrcode_svg.py index 337ab2a0..b4211cfc 100644 --- a/auth/totp_qrcode_svg.py +++ b/auth/totp_qrcode_svg.py @@ -5,11 +5,11 @@ import qrcode import qrcode.image.svg -def totp_provisioning_uri(secret, email): - return pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine") +def totp_provisioning_uri(secret: str, email: str) -> str: + return str(pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine")) -def totp_qrcode_svg(secret, email): +def totp_qrcode_svg(secret: str, email: str) -> str: """SVG (pas PNG) : la variante "image factory" par défaut de qrcode a besoin de Pillow pour produire un PNG — SvgPathImage, elle, est du pur Python, sans dépendance supplémentaire à installer juste pour un QR @@ -30,4 +30,4 @@ def totp_qrcode_svg(secret, email): buf = io.BytesIO() img.save(buf) svg = buf.getvalue().decode("utf-8") - return svg[svg.index(" str: new_email = (new_email or "").strip().lower() if not is_valid_email(new_email): raise EmailUpdateError("Adresse email invalide.") diff --git a/auth/update_profile.py b/auth/update_profile.py index ad9619da..a4354b53 100644 --- a/auth/update_profile.py +++ b/auth/update_profile.py @@ -1,7 +1,7 @@ from .connection import connect -def update_profile(user_id, nom, prenom): +def update_profile(user_id: int, nom: str, prenom: str) -> None: conn = connect() conn.execute("UPDATE _users SET nom = ?, prenom = ? WHERE id = ?", (nom, prenom, user_id)) conn.commit() diff --git a/auth/update_user_asset_scene_kind.py b/auth/update_user_asset_scene_kind.py new file mode 100644 index 00000000..328073d9 --- /dev/null +++ b/auth/update_user_asset_scene_kind.py @@ -0,0 +1,29 @@ +from .connection import connect +from .ensure_user_assets_schema import ensure_user_assets_schema +from .get_user_asset import get_user_asset +from .user_asset_kind import user_asset_kind + + +def update_user_asset_scene_kind(asset_id: int, user_id: int, scene_kind: str | None) -> bool: + """Reclasse une image déjà importée entre "Fonds" et "Décors/objets" + (voir create_user_asset.py) — SANS ré-upload (demande implicite, + trouvée en diagnostiquant un "décalage" : une image importée AVANT + la séparation fond/décor, ou par le mauvais bouton, restait bloquée + dans la mauvaise section, impossible à poser comme fond sans la + ré-importer en double). Ne touche jamais un objet DÉJÀ posé sur une + scène (kind="decor" dans _scene_objects) — l'auteur doit re-poser + l'image depuis la bonne section après reclassement, même geste + qu'un ajout normal.""" + if scene_kind not in ("decor", "fond"): + return False + ensure_user_assets_schema() + asset = get_user_asset(asset_id) + if not asset or asset["user_id"] != user_id: + return False + if user_asset_kind(asset["filename"]) != "image": + return False + conn = connect() + conn.execute("UPDATE _user_assets SET scene_kind = ? WHERE id = ?", (scene_kind, asset_id)) + conn.commit() + conn.close() + return True diff --git a/auth/user_asset_kind.py b/auth/user_asset_kind.py new file mode 100644 index 00000000..dcfbabc5 --- /dev/null +++ b/auth/user_asset_kind.py @@ -0,0 +1,25 @@ +import os + +# "image"/"audio"/"video"/"other" d'après l'EXTENSION du fichier (voir +# _user_assets.filename, auth/save_user_asset.py) — pas de colonne dédiée +# en base : l'extension suffit, jamais ambiguë ici (tout upload passe par +# routes/assets/upload_user_asset.py, qui valide déjà le contenu pour un +# son, voir MAX_AUDIO_SECONDS). Utilisé pour organiser "Mes assets" en +# sous-sections (demande explicite : "voir, utiliser ou supprimer des +# son, image et vidéo", voir templates/scene_edit.html) — le sélecteur de +# fichier des actions "son"/"vidéo" (user_assets_options_json, +# routes/scenes/scene_edit_view.py), lui, liste TOUT sans distinction. +_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"} +_AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"} +_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".ogv"} + + +def user_asset_kind(filename: str) -> str: + ext = os.path.splitext(filename)[1].lower() + if ext in _IMAGE_EXTENSIONS: + return "image" + if ext in _AUDIO_EXTENSIONS: + return "audio" + if ext in _VIDEO_EXTENSIONS: + return "video" + return "other" diff --git a/auth/user_assets_dir.py b/auth/user_assets_dir.py new file mode 100644 index 00000000..c5c6afad --- /dev/null +++ b/auth/user_assets_dir.py @@ -0,0 +1,10 @@ +import os + +from db.constants import USER_ASSETS_DIR + + +def user_assets_dir(user_id: int) -> str: + """Dossier disque des images d'un compte (voir ensure_user_assets_schema.py + pour les métadonnées) — mirroir de db/game_dir.py, mais indexé par + utilisateur plutôt que par slug de projet.""" + return os.path.join(USER_ASSETS_DIR, str(user_id)) diff --git a/auth/validate_audio_duration.py b/auth/validate_audio_duration.py new file mode 100644 index 00000000..f2be9ff6 --- /dev/null +++ b/auth/validate_audio_duration.py @@ -0,0 +1,25 @@ +import io + +from mutagen import File as MutagenFile + +# Demande explicite : un son importé dans "Mes assets" ne doit jamais +# dépasser 3 minutes (durée d'un effet sonore/d'une courte musique +# d'ambiance — pas un morceau entier, voir l'action "son", +# screens/rendering/collision_rules.py). +MAX_AUDIO_SECONDS = 180 + + +def validate_audio_duration(content: bytes, max_seconds: int = MAX_AUDIO_SECONDS) -> str | None: + """Lit la durée d'un fichier audio (mp3/wav/ogg/m4a/... — mutagen + détecte le format lui-même, pas besoin de le lui préciser) et renvoie + un message d'erreur (français, prêt à afficher) si elle dépasse + `max_seconds`, ou si le format n'a pas pu être reconnu du tout (repli + prudent : un fichier illisible n'est jamais accepté silencieusement). + Renvoie None si la durée est correcte.""" + audio = MutagenFile(io.BytesIO(content)) + if audio is None or audio.info is None or not getattr(audio.info, "length", None): + return "Format audio non reconnu." + duration = audio.info.length + if duration > max_seconds: + return f"Ce son dépasse {max_seconds // 60} minutes (durée : {int(duration)} secondes)." + return None diff --git a/auth/validate_video_duration.py b/auth/validate_video_duration.py new file mode 100644 index 00000000..b8789e45 --- /dev/null +++ b/auth/validate_video_duration.py @@ -0,0 +1,34 @@ +import io + +from mutagen import File as MutagenFile + +# Demande explicite : une vidéo importée dans "Mes assets" ne doit jamais +# dépasser 5 minutes (voir l'action "vidéo", screens/rendering/ +# collision_rules.py — pensée pour une courte cinématique/cutscene, pas un +# film entier), même esprit que MAX_AUDIO_SECONDS pour un son. +MAX_VIDEO_SECONDS = 300 + + +def validate_video_duration( + content: bytes, filename: str = "video.mp4", max_seconds: int = MAX_VIDEO_SECONDS +) -> str | None: + """Lit la durée d'un fichier vidéo MP4 (seul format accepté à l'upload, + voir routes/assets/upload_user_asset.py — mutagen ne sait pas lire + fiablement la durée de webm/mov/avi, contrairement au conteneur MP4/ + MOV ISO base media, qu'il décode via mutagen.mp4) et renvoie un + message d'erreur (français, prêt à afficher) si elle dépasse + `max_seconds`, ou si le format n'a pas pu être reconnu du tout (repli + prudent : un fichier illisible n'est jamais accepté silencieusement). + Renvoie None si la durée est correcte. + + filename (juste l'extension importe) : contrairement à un son (voir + validate_audio_duration.py), la détection MP4 de mutagen SANS indice + de nom de fichier échoue silencieusement sur certains fichiers + (moov/ftyp minimaux) — passer ".mp4" explicitement la rend fiable.""" + video = MutagenFile(io.BytesIO(content), filename=filename) + if video is None or video.info is None or not getattr(video.info, "length", None): + return "Format vidéo non reconnu." + duration = video.info.length + if duration > max_seconds: + return f"Cette vidéo dépasse {max_seconds // 60} minutes (durée : {int(duration)} secondes)." + return None diff --git a/auth/verify_password.py b/auth/verify_password.py index 905a31d7..863fcf54 100644 --- a/auth/verify_password.py +++ b/auth/verify_password.py @@ -1,5 +1,7 @@ +from typing import Any + from werkzeug.security import check_password_hash -def verify_password(user, password): - return bool(user) and check_password_hash(user["password_hash"], password or "") +def verify_password(user: dict[str, Any] | None, password: str | None) -> bool: + return user is not None and bool(check_password_hash(user["password_hash"], password or "")) diff --git a/auth/verify_totp.py b/auth/verify_totp.py index 7dfbe530..5e89570a 100644 --- a/auth/verify_totp.py +++ b/auth/verify_totp.py @@ -1,7 +1,7 @@ import pyotp -def verify_totp(secret, code): +def verify_totp(secret: str, code: str | None) -> bool: """valid_window=1 : tolère un léger décalage d'horloge entre le serveur et le téléphone (accepte aussi le code de la période précédente/suivante, ±30s) — sans quoi une horloge un peu désynchronisée @@ -9,4 +9,4 @@ def verify_totp(secret, code): code = (code or "").strip() if not code: return False - return pyotp.TOTP(secret).verify(code, valid_window=1) + return bool(pyotp.TOTP(secret).verify(code, valid_window=1)) diff --git a/build_css.py b/build_css.py index 9664d216..29c12ca2 100644 --- a/build_css.py +++ b/build_css.py @@ -13,6 +13,7 @@ variables/règles posées par le précédent : Lancer `python build_css.py` après toute modification sous styles/ ou static/vendor/bulma.min.css.""" + import os _BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -27,8 +28,8 @@ _SOURCES = [ ] -def build(): - chunks = [] +def build() -> None: + chunks: list[str] = [] for path in _SOURCES: with open(path, encoding="utf-8") as f: chunks.append(f"/* ---- {os.path.relpath(path, _BASE_DIR)} ---- */\n" + f.read()) diff --git a/core/auth_guard.py b/core/auth_guard.py index ec82e5bc..a862c167 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -11,7 +11,9 @@ pour que `app.url_map` connaisse déjà toutes les routes au moment où ce module tente de résoudre request.endpoint — en pratique sans importance ici (la résolution se fait à la requête, pas à l'import), mais gardé pour rester cohérent avec l'ordre d'import du reste du moteur.""" -from flask import g, redirect, request, session, url_for, abort + +from flask import abort, g, redirect, request, session, url_for +from werkzeug.wrappers import Response import auth from db.games.project_slug import split_slug @@ -22,8 +24,14 @@ from .flask_app import app # session valide. "static" (CSS/JS/images) doit rester public : la page # de connexion elle-même en a besoin pour s'afficher. _PUBLIC_ENDPOINTS = { - "static", "login", "login_2fa", "register", "register_2fa", "logout", - "forgot_password", "reset_password", + "static", + "login", + "login_2fa", + "register", + "register_2fa", + "logout", + "forgot_password", + "reset_password", } # Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion @@ -32,13 +40,18 @@ _PUBLIC_ENDPOINTS = { # quand même pouvoir changer son mot de passe, son email, ou supprimer # son compte. _REACHABLE_WITHOUT_PROJECT = { - "onboarding_new", "profile", "profile_update_name", "profile_update_email", - "profile_regenerate_recovery_codes", "profile_change_password", "profile_delete", + "onboarding_new", + "profile", + "profile_update_name", + "profile_update_email", + "profile_regenerate_recovery_codes", + "profile_change_password", + "profile_delete", } @app.before_request -def _require_login_and_enforce_project_isolation(): +def _require_login_and_enforce_project_isolation() -> Response | None: endpoint = request.endpoint if endpoint is None or endpoint in _PUBLIC_ENDPOINTS: return None diff --git a/core/csrf.py b/core/csrf.py index a925949c..20db03c7 100644 --- a/core/csrf.py +++ b/core/csrf.py @@ -6,6 +6,7 @@ un fetch() construit à la main (screen_edit.html, game_dashboard.html, play.html...) — voir static/csrf_fetch.js, qui l'ajoute automatiquement en en-tête à CHAQUE fetch() non-GET de l'appli plutôt que de devoir modifier individuellement chacun des nombreux appels existants.""" + import secrets from flask import session @@ -13,12 +14,12 @@ from flask import session from .flask_app import app -def get_csrf_token(): +def get_csrf_token() -> str: token = session.get("csrf_token") if not token: token = secrets.token_urlsafe(32) session["csrf_token"] = token - return token + return str(token) app.jinja_env.globals["csrf_token"] = get_csrf_token diff --git a/core/csrf_guard.py b/core/csrf_guard.py index 1a98e185..9c14a2cd 100644 --- a/core/csrf_guard.py +++ b/core/csrf_guard.py @@ -12,6 +12,7 @@ X-CSRFToken sur toute requête non-GET de l'appli (celles de pjax.js comprises) : pas besoin de modifier individuellement les nombreux appels fetch() déjà écrits à la main dans screen_edit.html/game_dashboard.html/ play.html.""" + from flask import abort, request, session from .flask_app import app @@ -20,7 +21,7 @@ _SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} @app.before_request -def _verify_csrf_token(): +def _verify_csrf_token() -> None: if request.method in _SAFE_METHODS: return None if app.config.get("TESTING"): diff --git a/core/db_teardown_guard.py b/core/db_teardown_guard.py new file mode 100644 index 00000000..1de95ff9 --- /dev/null +++ b/core/db_teardown_guard.py @@ -0,0 +1,11 @@ +"""Enregistre le filet de sécurité de fermeture des connexions SQLite +fuitées (voir db/connection.py::install_teardown_safety_net) sur l'app +Flask — vit dans core/ (couche de câblage qui branche les autres paquets +sur l'app), jamais l'inverse : db/ ne doit importer aucun paquet au-dessus +de lui dans la hiérarchie (voir contrat import-linter, pyproject.toml).""" + +from db.connection import install_teardown_safety_net + +from .flask_app import app + +install_teardown_safety_net(app) diff --git a/core/flask_app.py b/core/flask_app.py index effee6e5..8e35f85a 100644 --- a/core/flask_app.py +++ b/core/flask_app.py @@ -1,16 +1,29 @@ import os import secrets +from dotenv import load_dotenv from flask import Flask +# Charge .env AVANT tout le reste (voir .env.example) — ce module est le +# tout premier import interne de app.py, donc les variables sont posées +# avant qu'un `import db`/`auth` ne les résolve à l'import (ex. +# db/constants.py::PROJECTS_DIR). Ne remplace JAMAIS une variable déjà +# présente dans os.environ (comportement par défaut de load_dotenv) — +# tests/conftest.py, qui pose ses propres variables en Python avant tout +# import, reste donc isolé d'un .env local même s'il en existe un sur le +# poste. Aucun effet en production (pas de fichier .env sur le serveur, +# les variables y sont posées directement sur l'hôte/le conteneur). +load_dotenv() + _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates") _STATIC_FOLDER = os.path.join(_BASE_DIR, "static") -app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER) +# CSRF gere par core/csrf_guard.py (garde maison globale, testee dans test_csrf.py) - pas Flask-WTF. +app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER) # NOSONAR S4502 -def _load_or_create_secret_key(): +def _load_or_create_secret_key() -> str: """Nécessaire dès qu'une session Flask (flask.session) est utilisée — ici pour se souvenir de qui est connecté (auth/). Généré une seule fois et PERSISTÉ dans un fichier (jamais en dur dans le code, jamais diff --git a/core/recovery_codes_flash.py b/core/recovery_codes_flash.py index 02e435ca..e496e714 100644 --- a/core/recovery_codes_flash.py +++ b/core/recovery_codes_flash.py @@ -3,13 +3,15 @@ auth/recovery_codes.py et routes/auth/register_2fa.py) : posés en session au moment de la redirection qui suit leur génération, puis consommés (session.pop) dès le tout premier rendu de base.html qui suit — jamais revus après ce rendu, comme un message flash à usage unique.""" + from flask import session from .flask_app import app -def pop_recovery_codes(): - return session.pop("recovery_codes_to_show", None) +def pop_recovery_codes() -> list[str] | None: + codes = session.pop("recovery_codes_to_show", None) + return list(codes) if codes is not None else None app.jinja_env.globals["pop_recovery_codes"] = pop_recovery_codes diff --git a/core/sprite_gate.py b/core/sprite_gate.py index de8e05b1..97c50d04 100644 --- a/core/sprite_gate.py +++ b/core/sprite_gate.py @@ -6,12 +6,13 @@ screen_edit.py, routes/scenes/scene_edit_view.py) ne les propose déjà pas (element_add, scene_object_add, element_set_personnage_data, scene_object_personnage_data) pourrait contourner ce filtrage d'UI — ce garde-fou ferme cette brèche au moment de l'écriture.""" + from flask import abort, g import screens -def forbid_admin_only_character(forge_character): +def forbid_admin_only_character(forge_character: str | None) -> None: if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin": abort(403) @@ -21,6 +22,6 @@ def forbid_admin_only_character(forge_character): # voir screens/labels/background_library.py) — TOUJOURS admin-only # aujourd'hui (un seul pack, tous sous licence CraftPix), contrairement # aux personnages Forge/Kenney (domaine public, jamais gatés). -def forbid_admin_only_background(background_slug): +def forbid_admin_only_background(background_slug: str | None) -> None: if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and g.current_user["role"] != "admin": abort(403) diff --git a/db/__init__.py b/db/__init__.py index c7f0f0fd..23666a38 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -20,88 +20,123 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un vrai INSERT dans cette table. """ -from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS -from .slugify import slugify -from .table_name_for import table_name_for -from .game_dir import game_dir -from .db_path import db_path from .connection import connect - -from .games.list_games import list_games -from .games.game_meta import game_meta -from .games.get_game_type import get_game_type, DEFAULT_GAME_TYPE -from .games.create_game import create_game -from .games.update_game_name import update_game_name -from .games.delete_game import delete_game -from .games.move_game import move_game -from .games.get_xapi_settings import get_xapi_settings -from .games.set_xapi_settings import set_xapi_settings -from .games.get_success_threshold import get_success_threshold -from .games.set_success_threshold import set_success_threshold -from .games.get_scorm_version import get_scorm_version -from .games.set_scorm_version import set_scorm_version -from .games.game_type_catalog import ( - ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE, - get_onboarding_type, get_onboarding_type_raw, set_onboarding_type, - is_restricted, -) - -from .definitions.list_definitions import list_definitions -from .definitions.get_definition import get_definition +from .constants import FIELD_TYPES, GLOBAL_VARIABLE_TYPES, PROJECTS_DIR, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS +from .custom_events.create_custom_event import create_custom_event +from .custom_events.get_custom_event import get_custom_event +from .custom_events.list_custom_events import list_custom_events +from .custom_events.update_custom_event import update_custom_event +from .db_path import db_path from .definitions.create_definition import create_definition -from .definitions.rename_definition import rename_definition from .definitions.definitions_referencing import definitions_referencing -from .definitions.add_field_to_definition import add_field_to_definition -from .definitions.update_field import update_field -from .definitions.delete_field import delete_field from .definitions.delete_definition import delete_definition - -from .rows.list_rows import list_rows -from .rows.relation_options import relation_options -from .rows.insert_row import insert_row -from .rows.get_row import get_row -from .rows.update_row import update_row -from .rows.update_row_field import update_row_field -from .rows.delete_row import delete_row -from .rows.rows_referencing import rows_referencing - -from .global_vars.ensure_global_vars_schema import PLAYER_SHARED -from .global_vars.list_global_variables import list_global_variables -from .global_vars.list_global_variables_for_player import list_global_variables_for_player -from .global_vars.get_global_variable import get_global_variable +from .definitions.get_definition import get_definition +from .definitions.list_definitions import list_definitions +from .dialogue_lines import QUESTION_REWARD_TYPES, sanitize_dialogue_lines, sum_question_rewards +from .game_dir import game_dir +from .games.create_game import create_game +from .games.delete_game import delete_game +from .games.game_meta import game_meta +from .games.game_type_catalog import ( + DEFAULT_ONBOARDING_TYPE, + ONBOARDING_TYPES, + get_onboarding_type, + get_onboarding_type_raw, + is_restricted, + set_onboarding_type, +) +from .games.get_game_type import DEFAULT_GAME_TYPE, get_game_type +from .games.get_scorm_version import get_scorm_version +from .games.get_success_threshold import get_success_threshold +from .games.get_xapi_settings import get_xapi_settings +from .games.list_games import list_games +from .games.move_game import move_game +from .games.set_scorm_version import set_scorm_version +from .games.set_success_threshold import set_success_threshold +from .games.set_xapi_settings import set_xapi_settings +from .games.update_game_name import update_game_name from .global_vars.create_global_variable import create_global_variable -from .global_vars.update_global_variable_value import update_global_variable_value -from .global_vars.update_global_variable import update_global_variable from .global_vars.delete_global_variable import delete_global_variable from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id - +from .global_vars.ensure_global_vars_schema import PLAYER_SHARED +from .global_vars.get_global_variable import get_global_variable +from .global_vars.list_global_variables import list_global_variables +from .global_vars.list_global_variables_for_player import list_global_variables_for_player +from .global_vars.update_global_variable import update_global_variable +from .global_vars.update_global_variable_value import update_global_variable_value +from .json_for_script import json_for_script +from .rows.delete_row import delete_row +from .rows.get_row import get_row +from .rows.insert_row import insert_row +from .rows.list_rows import list_rows +from .rows.update_row import update_row +from .rows.update_row_field import update_row_field from .scoring.get_score import get_score from .scoring.set_score_value import set_score_value from .scoring.set_status import set_status - -from .custom_events.list_custom_events import list_custom_events -from .custom_events.get_custom_event import get_custom_event -from .custom_events.create_custom_event import create_custom_event -from .custom_events.update_custom_event import update_custom_event - -from .dialogue_lines import sanitize_dialogue_lines, QUESTION_REWARD_TYPES, sum_question_rewards +from .slugify import slugify +from .table_name_for import table_name_for __all__ = [ - "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", - "SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status", - "slugify", "table_name_for", "game_dir", "db_path", "connect", - "list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game", - "get_game_type", "DEFAULT_GAME_TYPE", - "ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type", - "get_onboarding_type_raw", "set_onboarding_type", "is_restricted", - "list_definitions", "get_definition", "create_definition", "rename_definition", - "definitions_referencing", "add_field_to_definition", "update_field", "delete_field", + "PROJECTS_DIR", + "FIELD_TYPES", + "GLOBAL_VARIABLE_TYPES", + "PLAYER_SHARED", + "SCORE_STATUS_CHOICES", + "SCORE_STATUS_LABELS", + "get_score", + "set_score_value", + "set_status", + "slugify", + "table_name_for", + "json_for_script", + "game_dir", + "db_path", + "connect", + "list_games", + "game_meta", + "create_game", + "update_game_name", + "delete_game", + "move_game", + "get_game_type", + "DEFAULT_GAME_TYPE", + "get_xapi_settings", + "set_xapi_settings", + "get_success_threshold", + "set_success_threshold", + "get_scorm_version", + "set_scorm_version", + "ONBOARDING_TYPES", + "DEFAULT_ONBOARDING_TYPE", + "get_onboarding_type", + "get_onboarding_type_raw", + "set_onboarding_type", + "is_restricted", + "list_definitions", + "get_definition", + "create_definition", + "definitions_referencing", "delete_definition", - "list_rows", "relation_options", "insert_row", "get_row", "update_row", - "update_row_field", "delete_row", "rows_referencing", - "list_global_variables", "list_global_variables_for_player", "get_global_variable", "create_global_variable", - "update_global_variable_value", "update_global_variable", "delete_global_variable", + "list_rows", + "insert_row", + "get_row", + "update_row", + "update_row_field", + "delete_row", + "list_global_variables", + "list_global_variables_for_player", + "get_global_variable", + "create_global_variable", + "update_global_variable_value", + "update_global_variable", + "delete_global_variable", "delete_global_variable_by_id", - "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", - "sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards", + "list_custom_events", + "get_custom_event", + "create_custom_event", + "update_custom_event", + "sanitize_dialogue_lines", + "QUESTION_REWARD_TYPES", + "sum_question_rewards", ] diff --git a/db/connection.py b/db/connection.py index 21805dae..f48289ae 100644 --- a/db/connection.py +++ b/db/connection.py @@ -1,9 +1,14 @@ +import contextlib import sqlite3 +from typing import TYPE_CHECKING from .db_path import db_path +if TYPE_CHECKING: + from flask import Flask -def connect(slug): + +def connect(slug: str) -> sqlite3.Connection: # timeout=10 : si une autre connexion tient un verrou d'écriture au même # instant (deux requêtes qui arrivent presque en même temps, ex. deux # onglets, ou le navigateur qui recharge plusieurs ressources), sqlite3 @@ -21,7 +26,7 @@ def connect(slug): return conn -def _track_for_teardown(conn): +def _track_for_teardown(conn: sqlite3.Connection) -> None: """Filet de sécurité : chaque fonction de db/ ouvre sa propre connexion et est censée la fermer elle-même (conn.close()) avant de rendre la main — mais si une exception survient ENTRE l'ouverture et cette @@ -50,23 +55,18 @@ def _track_for_teardown(conn): g._forge_db_connections.append(conn) -def _install_teardown_safety_net(): - """Appelé une seule fois (voir le bas de ce fichier) — enregistre le - filet de sécurité sur l'appli Flask. `core.flask_app` ne dépend de rien - dans `db/`, donc cet import ne crée pas de dépendance circulaire.""" - try: - from core.flask_app import app - except ImportError: - return +def install_teardown_safety_net(app: "Flask") -> None: + """Enregistre le filet de sécurité (voir _track_for_teardown) sur + l'appli Flask passée en paramètre — jamais importée ici : `db/` est la + couche la plus basse du moteur (voir pyproject.toml, contrat + import-linter) et ne doit dépendre d'aucun autre paquet. C'est + core/db_teardown_guard.py, dans la couche de câblage, qui appelle + cette fonction avec `core.flask_app.app`.""" @app.teardown_request - def _close_leaked_connections(exception=None): # noqa: ARG001 - signature imposée par Flask + def _close_leaked_connections(exception: BaseException | None = None) -> None: # noqa: ARG001 - signature imposée par Flask from flask import g + for conn in getattr(g, "_forge_db_connections", ()): - try: + with contextlib.suppress(sqlite3.Error): conn.close() - except sqlite3.Error: - pass - - -_install_teardown_safety_net() diff --git a/db/constants.py b/db/constants.py index 5eead8a7..405d6e9e 100644 --- a/db/constants.py +++ b/db/constants.py @@ -10,6 +10,15 @@ PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects" ) +# "Mes assets" (voir auth/user_assets/) : stockage PAR UTILISATEUR, pas +# par jeu — distinct de PROJECTS_DIR///uploads (voir +# routes/uploads/upload_file.py), pour qu'une image reste réutilisable +# d'un projet à l'autre du même compte. Même schéma de surcharge que +# PROJECTS_DIR ci-dessus. +USER_ASSETS_DIR = os.environ.get("FORGE_USER_ASSETS_DIR") or os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "user_assets" +) + # Types de champ exposés dans l'interface -> type de colonne SQLite réel. FIELD_TYPES = { "texte": {"label": "Texte court", "sql": "TEXT"}, diff --git a/db/custom_events/create_custom_event.py b/db/custom_events/create_custom_event.py index ce60dbb0..81abf015 100644 --- a/db/custom_events/create_custom_event.py +++ b/db/custom_events/create_custom_event.py @@ -2,7 +2,7 @@ from ..connection import connect from .ensure_custom_events_schema import ensure_custom_events_schema -def create_custom_event(slug, name, description=""): +def create_custom_event(slug: str, name: str, description: str = "") -> int | None: """Idempotent par nom (même convention que create_global_variable.py) : si le nom existe déjà, ne touche à rien et renvoie simplement son id existant plutôt que de lever une erreur — sans risque en cas de @@ -15,12 +15,12 @@ def create_custom_event(slug, name, description=""): existing = conn.execute("SELECT id FROM _custom_events WHERE name = ?", (name,)).fetchone() if existing: conn.close() - return existing["id"] + return int(existing["id"]) conn.execute( "INSERT INTO _custom_events (name, description) VALUES (?, ?)", (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 diff --git a/db/custom_events/ensure_custom_events_schema.py b/db/custom_events/ensure_custom_events_schema.py index d7bfaaaf..78e70ad6 100644 --- a/db/custom_events/ensure_custom_events_schema.py +++ b/db/custom_events/ensure_custom_events_schema.py @@ -1,7 +1,7 @@ from ..connection import connect -def ensure_custom_events_schema(slug): +def ensure_custom_events_schema(slug: str) -> None: """Migration légère (même principe que ensure_global_vars_schema.py) : crée _custom_events si absente. Un événement personnalisé vit pour TOUT le jeu (pas par écran, pas par modèle) : "name" est donc UNIQUE — diff --git a/db/custom_events/get_custom_event.py b/db/custom_events/get_custom_event.py index 453deb07..c7b2a466 100644 --- a/db/custom_events/get_custom_event.py +++ b/db/custom_events/get_custom_event.py @@ -1,8 +1,10 @@ +from typing import Any + from ..connection import connect from .ensure_custom_events_schema import ensure_custom_events_schema -def get_custom_event(slug, event_id): +def get_custom_event(slug: str, event_id: int | None) -> dict[str, Any] | None: if not event_id: return None ensure_custom_events_schema(slug) diff --git a/db/custom_events/list_custom_events.py b/db/custom_events/list_custom_events.py index 624fb80c..c6ed8a1a 100644 --- a/db/custom_events/list_custom_events.py +++ b/db/custom_events/list_custom_events.py @@ -1,8 +1,10 @@ +from typing import Any + from ..connection import connect from .ensure_custom_events_schema import ensure_custom_events_schema -def list_custom_events(slug): +def list_custom_events(slug: str) -> list[dict[str, Any]]: ensure_custom_events_schema(slug) conn = connect(slug) rows = conn.execute("SELECT * FROM _custom_events ORDER BY name").fetchall() diff --git a/db/custom_events/update_custom_event.py b/db/custom_events/update_custom_event.py index b548114d..ff0a9d40 100644 --- a/db/custom_events/update_custom_event.py +++ b/db/custom_events/update_custom_event.py @@ -2,7 +2,7 @@ from ..connection import connect from .ensure_custom_events_schema import ensure_custom_events_schema -def update_custom_event(slug, event_id, description=""): +def update_custom_event(slug: str, event_id: int, description: str = "") -> None: """Le NOM reste volontairement immuable après création — comme une variable globale (voir update_global_variable.py) : c'est par ce nom qu'on désigne l'événement dans l'interface, mais surtout par son ID diff --git a/db/db_path.py b/db/db_path.py index 31491d58..8829baff 100644 --- a/db/db_path.py +++ b/db/db_path.py @@ -3,5 +3,5 @@ import os from .game_dir import game_dir -def db_path(slug): +def db_path(slug: str) -> str: return os.path.join(game_dir(slug), "game.db") diff --git a/db/definitions/add_field_to_definition.py b/db/definitions/add_field_to_definition.py deleted file mode 100644 index ae2835cd..00000000 --- a/db/definitions/add_field_to_definition.py +++ /dev/null @@ -1,44 +0,0 @@ -from ..connection import connect -from ..constants import FIELD_TYPES -from ..quote_ident import quote_ident -from ..slugify import slugify -from .get_definition import get_definition - - -def add_field_to_definition(slug, definition_id, field): - """CRUD — Update d'une définition : ajoute un champ à un objet déjà - créé. Exécute un vrai ALTER TABLE ... ADD COLUMN sur la table SQL - existante (les lignes déjà enregistrées reçoivent NULL pour ce - nouveau champ, comportement standard d'un ALTER TABLE).""" - definition = get_definition(slug, definition_id) - conn = connect(slug) - fname = slugify(field["name"]).replace("-", "_") - ftype = field["type"] - required = 1 if field.get("required") else 0 - relation_definition_id = None - position = (max((f["position"] for f in definition["fields"]), default=-1)) + 1 - - if ftype == "relation": - related = get_definition(slug, int(field["relation_definition_id"])) - col = f"{fname}_id" - # ALTER TABLE ADD COLUMN de SQLite n'accepte pas de contrainte - # REFERENCES portant sur une colonne ajoutée après coup avec la - # même simplicité qu'à la création : on ajoute la colonne simple — - # c'est la table _fields qui reste la source de vérité utilisée par - # le moteur pour savoir que cette colonne est une relation. - conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {quote_ident(col)} INTEGER") - relation_definition_id = related["id"] - else: - sql_type = FIELD_TYPES[ftype]["sql"] - conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {quote_ident(fname)} {sql_type}") - - min_value = field.get("min_value") if ftype in ("nombre_entier", "nombre_decimal") else None - max_value = field.get("max_value") if ftype in ("nombre_entier", "nombre_decimal") else None - conn.execute( - """INSERT INTO _fields - (definition_id, name, type, relation_definition_id, required, position, min_value, max_value) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - (definition_id, field["name"], ftype, relation_definition_id, required, position, min_value, max_value), - ) - conn.commit() - conn.close() diff --git a/db/definitions/create_definition.py b/db/definitions/create_definition.py index 82b8461e..7430bc27 100644 --- a/db/definitions/create_definition.py +++ b/db/definitions/create_definition.py @@ -1,13 +1,15 @@ +from typing import Any + from ..connection import connect from ..constants import FIELD_TYPES from ..quote_ident import quote_ident from ..slugify import slugify from ..table_name_for import table_name_for -from .get_definition import get_definition from .ensure_field_bounds_schema import ensure_field_bounds_schema +from .get_definition import get_definition -def create_definition(slug, name, fields, per_player=True): +def create_definition(slug: str, name: str, fields: list[dict[str, Any]], per_player: bool = True) -> int: """Feature 2 : crée une définition d'objet (comme une table de BDD) et exécute le vrai CREATE TABLE correspondant, avec les bons types de colonne, y compris les colonnes de clé étrangère pour les relations @@ -30,7 +32,7 @@ def create_definition(slug, name, fields, per_player=True): "INSERT INTO _definitions (name, table_name, per_player) VALUES (?, ?, ?)", (name, tname, 1 if per_player else 0), ) - definition_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + definition_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]) columns_sql = ["id INTEGER PRIMARY KEY AUTOINCREMENT"] for pos, f in enumerate(fields): @@ -41,10 +43,13 @@ def create_definition(slug, name, fields, per_player=True): if ftype == "relation": related = get_definition(slug, int(f["relation_definition_id"])) + if related is None: + raise ValueError( + f"relation_definition_id {f['relation_definition_id']!r} ne correspond " + "a aucune definition existante" + ) col = f"{fname}_id" - columns_sql.append( - f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)" - ) + columns_sql.append(f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)") relation_definition_id = related["id"] else: sql_type = FIELD_TYPES[ftype]["sql"] diff --git a/db/definitions/definitions_referencing.py b/db/definitions/definitions_referencing.py index 51f7cacf..25d45ec1 100644 --- a/db/definitions/definitions_referencing.py +++ b/db/definitions/definitions_referencing.py @@ -1,7 +1,9 @@ +from typing import Any + from ..connection import connect -def definitions_referencing(slug, definition_id): +def definitions_referencing(slug: str, definition_id: int) -> list[dict[str, Any]]: """Autres définitions de CE jeu qui ont un champ de type relation pointant vers cette définition — utilisé pour bloquer une suppression qui casserait ces relations.""" diff --git a/db/definitions/delete_definition.py b/db/definitions/delete_definition.py index 719e18ed..0d008470 100644 --- a/db/definitions/delete_definition.py +++ b/db/definitions/delete_definition.py @@ -2,8 +2,10 @@ from ..connection import connect from .get_definition import get_definition -def delete_definition(slug, definition_id): +def delete_definition(slug: str, definition_id: int) -> None: definition = get_definition(slug, definition_id) + if definition is None: + raise ValueError(f"definition_id {definition_id!r} ne correspond a aucune definition existante") conn = connect(slug) conn.execute(f"DROP TABLE IF EXISTS {definition['table_name']}") conn.execute("DELETE FROM _fields WHERE definition_id = ?", (definition_id,)) diff --git a/db/definitions/delete_field.py b/db/definitions/delete_field.py deleted file mode 100644 index a6a4d008..00000000 --- a/db/definitions/delete_field.py +++ /dev/null @@ -1,29 +0,0 @@ -import sqlite3 - -from ..connection import connect -from ..quote_ident import quote_ident -from ..slugify import slugify -from .get_definition import get_definition - - -def delete_field(slug, definition_id, field_id): - """CRUD — Update d'une définition : retire un champ. Exécute un vrai - ALTER TABLE ... DROP COLUMN (SQLite ≥ 3.35). Sur une version de SQLite - trop ancienne pour DROP COLUMN, le champ est retiré de la définition - (le moteur ne le proposera plus dans les formulaires) mais la colonne - SQL peut subsister sans casser quoi que ce soit d'autre.""" - definition = get_definition(slug, definition_id) - field = next((f for f in definition["fields"] if f["id"] == field_id), None) - if not field: - return - col = slugify(field["name"]).replace("-", "_") - if field["type"] == "relation": - col += "_id" - conn = connect(slug) - try: - conn.execute(f"ALTER TABLE {definition['table_name']} DROP COLUMN {quote_ident(col)}") - except sqlite3.OperationalError: - pass - conn.execute("DELETE FROM _fields WHERE id = ?", (field_id,)) - conn.commit() - conn.close() diff --git a/db/definitions/ensure_field_bounds_schema.py b/db/definitions/ensure_field_bounds_schema.py index 204ea533..9e12db0f 100644 --- a/db/definitions/ensure_field_bounds_schema.py +++ b/db/definitions/ensure_field_bounds_schema.py @@ -1,7 +1,7 @@ from ..connection import connect -def ensure_field_bounds_schema(slug): +def ensure_field_bounds_schema(slug: str) -> None: """Migration légère (voir screens/screens_repo/ensure_schema.py pour le même principe) : ajoute les colonnes min_value/max_value à _fields pour les jeux créés avant le bornage automatique (2.2), et per_player à diff --git a/db/definitions/get_definition.py b/db/definitions/get_definition.py index c149774d..bc2e10d9 100644 --- a/db/definitions/get_definition.py +++ b/db/definitions/get_definition.py @@ -1,8 +1,10 @@ +from typing import Any + from ..connection import connect from .ensure_field_bounds_schema import ensure_field_bounds_schema -def get_definition(slug, definition_id): +def get_definition(slug: str, definition_id: int) -> dict[str, Any] | None: ensure_field_bounds_schema(slug) conn = connect(slug) d = conn.execute("SELECT * FROM _definitions WHERE id = ?", (definition_id,)).fetchone() diff --git a/db/definitions/list_definitions.py b/db/definitions/list_definitions.py index 526d501e..ca5c38c9 100644 --- a/db/definitions/list_definitions.py +++ b/db/definitions/list_definitions.py @@ -1,8 +1,10 @@ +from typing import Any + from ..connection import connect from .ensure_field_bounds_schema import ensure_field_bounds_schema -def list_definitions(slug): +def list_definitions(slug: str) -> list[dict[str, Any]]: ensure_field_bounds_schema(slug) conn = connect(slug) rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall() diff --git a/db/definitions/rename_definition.py b/db/definitions/rename_definition.py deleted file mode 100644 index 465b3787..00000000 --- a/db/definitions/rename_definition.py +++ /dev/null @@ -1,8 +0,0 @@ -from ..connection import connect - - -def rename_definition(slug, definition_id, new_name): - conn = connect(slug) - conn.execute("UPDATE _definitions SET name = ? WHERE id = ?", (new_name, definition_id)) - conn.commit() - conn.close() diff --git a/db/definitions/update_field.py b/db/definitions/update_field.py deleted file mode 100644 index 3cc64c8e..00000000 --- a/db/definitions/update_field.py +++ /dev/null @@ -1,49 +0,0 @@ -import sqlite3 - -from ..connection import connect -from ..quote_ident import quote_ident -from ..slugify import slugify -from .get_definition import get_definition - - -def update_field(slug, definition_id, field_id, new_name, required, relation_definition_id=None, min_value=None, max_value=None): - """CRUD — Update d'une définition : modifie un champ déjà créé (nom, - obligatoire, objet lié si c'est une relation, et bornes mini/maxi si - c'est un champ numérique — voir 2.2, bornage automatique). Si le nom - change, la vraie colonne SQL est renommée (ALTER TABLE ... RENAME - COLUMN, SQLite ≥ 3.25) pour que la colonne réelle continue de - correspondre exactement au nom du champ tel qu'affiché — pas de dérive - entre la définition et la table. Le TYPE d'un champ existant ne se - change pas ici (une vraie conversion de type SQLite demanderait de - reconstruire la table et de convertir les données déjà enregistrées, - hors scope de cette version).""" - definition = get_definition(slug, definition_id) - field = next((f for f in definition["fields"] if f["id"] == field_id), None) - if not field: - return - - old_col = slugify(field["name"]).replace("-", "_") - new_col = slugify(new_name).replace("-", "_") - if field["type"] == "relation": - old_col += "_id" - new_col += "_id" - - conn = connect(slug) - if old_col != new_col: - try: - conn.execute( - f"ALTER TABLE {definition['table_name']} RENAME COLUMN {quote_ident(old_col)} TO {quote_ident(new_col)}" - ) - except sqlite3.OperationalError: - pass # SQLite trop ancien pour RENAME COLUMN : la colonne SQL garde son ancien nom - - rel_id = int(relation_definition_id) if (field["type"] == "relation" and relation_definition_id) else field["relation_definition_id"] - is_numeric = field["type"] in ("nombre_entier", "nombre_decimal") - min_v = (min_value if min_value not in (None, "") else None) if is_numeric else None - max_v = (max_value if max_value not in (None, "") else None) if is_numeric else None - conn.execute( - "UPDATE _fields SET name = ?, required = ?, relation_definition_id = ?, min_value = ?, max_value = ? WHERE id = ?", - (new_name, 1 if required else 0, rel_id, min_v, max_v, field_id), - ) - conn.commit() - conn.close() diff --git a/db/dialogue_lines.py b/db/dialogue_lines.py index c5a542ef..6b4faefa 100644 --- a/db/dialogue_lines.py +++ b/db/dialogue_lines.py @@ -1,3 +1,5 @@ +from typing import Any + _MAX_LINES_PER_COLUMN = 200 _MAX_SPEAKER_LENGTH = 60 @@ -12,17 +14,27 @@ _MIN_CHOICES = 2 _MAX_CHOICES = 4 -def _sanitize_dialogue_line(raw_line): +def _sanitize_dialogue_line(raw_line: dict[str, Any]) -> dict[str, Any] | None: speaker = raw_line.get("speaker") text = raw_line.get("text") if not isinstance(speaker, str) or not speaker.strip(): return None if not isinstance(text, str) or not text.strip(): return None - return {"type": "dialogue", "speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()} + line = {"type": "dialogue", "speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()} + # Réplique audio (demande explicite : "ajouter une réplique audio") + # — une voix off jouée en même temps que la bulle s'affiche, voir + # "Mes assets" (un fichier déjà importé, jamais un chemin arbitraire + # posté à la main : la sélection se fait via un