Compare commits
19
Commits
b1c995ab46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3260cde122 | ||
|
|
ccf836f2c5 | ||
|
|
66d8eaeae8 | ||
|
|
8e6c176072 | ||
|
|
55e81c0fbb | ||
|
|
20f9398bd4 | ||
|
|
2ff127f68e | ||
|
|
c57420c8c9 | ||
|
|
7db4803b93 | ||
|
|
559331f9cf | ||
|
|
b07b231a61 | ||
|
|
9887d50cc7 | ||
|
|
ab6635eee0 | ||
|
|
d664ed5637 | ||
|
|
45c9f97b5d | ||
|
|
c504ace167 | ||
|
|
f7a50e5afe | ||
|
|
a16a30683c | ||
|
|
7ebc9b143f |
+32
-4
@@ -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
|
||||
|
||||
+191
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+100
-10
@@ -4,12 +4,25 @@ 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) — remis en place le
|
||||
# 18/09/2026 une fois l'instance prod opérationnelle (voir CODE_QUALITY.md
|
||||
# pour l'historique du retrait temporaire), le temps que le rapport de
|
||||
# code smells soit entièrement trié.
|
||||
|
||||
# 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 +33,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 +83,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/play/offline/__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:
|
||||
@@ -125,8 +204,19 @@ jobs:
|
||||
cd "${{ secrets.DEPLOY_PATH }}"
|
||||
echo "REGISTRY_IMAGE=${{ secrets.REGISTRY_IMAGE }}" > .env
|
||||
echo "IMAGE_TAG=${{ gitea.sha }}" >> .env
|
||||
docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_TOKEN }}"
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
docker image prune -f
|
||||
'
|
||||
|
||||
- name: Nettoyage de la cle SSH
|
||||
# if: always() - meme si les etapes precedentes ont echoue, la cle
|
||||
# privee posee ci-dessus (~/.ssh/deploy_key) ne doit jamais rester
|
||||
# sur le disque du job. Le runner semble deja jetable (voir
|
||||
# "docker volume rm .../JOB-<id>-..." dans les logs des autres
|
||||
# jobs de ce meme workflow), mais jamais verifie directement pour
|
||||
# CE job (jamais execute sur dev, reserve a main) - ne pas se fier
|
||||
# uniquement a une inference par analogie pour une cle privee.
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
|
||||
+23
@@ -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/
|
||||
|
||||
@@ -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 <script.js>` 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$
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
## Stack et structure
|
||||
Bref rappel : Python/Flask/Jinja backend, JS/HTML/CSS frontend, architecture en couches (routes → services/screens → db, voir contrat import-linter dans pyproject.toml).
|
||||
|
||||
## Règle absolue : qualité de code non négociable
|
||||
- Tout code Python doit passer mypy --strict sans erreur. Jamais de # type: ignore sans commentaire justifiant précisément pourquoi (pas de vague, une vraie raison technique).
|
||||
- Tout code doit passer Ruff, Bandit, Vulture, import-linter, ESLint, Stylelint sans nouvelle erreur avant d'être considéré terminé.
|
||||
- Avant de committer, lance systématiquement pre-commit run --all-files et corrige tout ce qui échoue — jamais de --no-verify ou de SKIP= sans me le signaler et justifier explicitement pourquoi.
|
||||
- Aucune règle de lint/typage ne doit être désactivée globalement dans un fichier de config sans ma validation explicite. Une exception ponctuelle (# noqa, # nosec, // NOSONAR, ts-ignore) doit toujours être sur la ligne concernée avec un commentaire expliquant précisément pourquoi, jamais un ignore de fichier entier ou de règle globale.
|
||||
|
||||
## Distinguer bug réel vs faux positif
|
||||
Avant de corriger un finding d'un outil (Mypy, Bandit, Sonar, etc.), détermine s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (ex. injection de fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur). Ne corrige jamais mécaniquement sans comprendre la cause. Si un correctif change un comportement (pas juste une annotation/un style), signale-le explicitement avant de l'appliquer et explique pourquoi.
|
||||
|
||||
## Tests unitaires — non-régression
|
||||
- Toute nouvelle fonctionnalité ou tout correctif de bug doit être accompagné d'un test qui aurait échoué avant le fix et passe après.
|
||||
- Ne jamais me dire "les tests passent" comme preuve suffisante pour un changement de sécurité ou de comportement sensible (ex. échappement de données, gestion de permissions) — écris un test ou un script de vérification dédié qui prouve concrètement le comportement attendu (voir l'exemple du fix XSS json_for_script : test avec une charge malveillante réelle, pas juste "aucune régression").
|
||||
- Ne réduis jamais la portée d'une assertion de test pour la faire passer sans discussion préalable avec moi.
|
||||
- Signale immédiatement toute fuite d'état entre tests (fixtures partagées, données non nettoyées) même si elle n'est pas dans le scope de la tâche en cours.
|
||||
|
||||
## Code mort
|
||||
Avant de supprimer une fonction jugée "morte" par un outil (Vulture, ESLint no-unused-vars), vérifie par grep exhaustif (imports directs, dispatch dynamique par nom de chaîne, référencé depuis un template Jinja ou un script JS, appel getattr/introspection) avant de conclure qu'elle est vraiment inutilisée.
|
||||
|
||||
## SonarQube local (développement continu)
|
||||
En plus de l'instance CI (sonar.forgebase.fr, non-bloquante), une instance SonarQube locale doit tourner via Docker pour un usage rapide pendant le développement, séparée de la CI.
|
||||
|
||||
### Mise en place (à faire une fois si l'instance n'existe pas encore)
|
||||
Si aucune instance locale n'est déjà en place : lance une instance SonarQube Community via Docker (image officielle, avec une base PostgreSQL plutôt que la H2 embarquée par défaut — voir la mise en place déjà documentée pour sonar.forgebase.fr comme référence). Communique-moi clairement l'adresse locale (ex. http://localhost:9000) une fois lancée, et le port utilisé, pour que je puisse ouvrir le dashboard moi-même à tout moment.
|
||||
|
||||
### Utilisation continue pendant le développement
|
||||
- Avant de considérer une tâche terminée (nouvelle fonctionnalité, gros refactor, fix de bug), lance une analyse sonar-scanner contre cette instance locale.
|
||||
- Si des erreurs ou vulnérabilités sont détectées sur du code NOUVEAU (écrit dans la session en cours) : corrige-les toi-même directement, comme pour Ruff/Mypy/Bandit — pas besoin de me demander la permission pour un vrai bug évident sur du code que tu viens d'écrire.
|
||||
- Si l'analyse détecte quelque chose sur du code existant (pas modifié dans la session en cours) : signale-le-moi, n'y touche pas sans validation, même logique que pour le rapport SonarQube de la Phase 3 (analyse d'abord, décision ensemble avant correction).
|
||||
- Distingue toujours dans ton compte-rendu : ce qui a été corrigé automatiquement (code neuf, évident) vs ce qui est signalé en attente de décision (code existant, ou correction ambiguë).
|
||||
- Donne-moi régulièrement l'adresse du dashboard local si je veux consulter moi-même le détail visuellement.
|
||||
|
||||
## Convention vs bug de config
|
||||
Si un preset de lint (ex. airbnb-base, stylelint-config-standard) contredit une convention cohérente déjà établie dans le code (ex. camelCase CSS, var au lieu de let/const), n'impose pas de réécriture mécanique du code : propose d'adapter la config, documente pourquoi, et attends ma validation.
|
||||
|
||||
## Discipline de communication
|
||||
- Pour tout lot de travail dépassant quelques fichiers, découpe en étapes vérifiables (diff + tests à chaque étape), jamais un correctif massif d'un coup.
|
||||
- Signale tout changement de comportement réel séparément du reste (pas noyé dans un rapport de style).
|
||||
- Si un choix a plusieurs options légitimes (garder/typer/supprimer, corriger/documenter une exception), présente les options avec leurs compromis plutôt que de trancher seul.
|
||||
|
||||
## Documentation
|
||||
Toute règle désactivée, tout # nosec/NOSONAR, toute adaptation de config doit être répercutée dans CODE_QUALITY.md.
|
||||
|
||||
## Organisation des fichiers (applicable à partir de maintenant, pas de refactor rétroactif)
|
||||
|
||||
- 1 fichier = 1 fonction publique. Les fonctions privées d'appui (préfixées _) utilisées uniquement par cette fonction restent dans le même fichier.
|
||||
- 1 dossier = 1 responsabilité claire.
|
||||
- Chaque dossier a un fichier barrel (__init__.py) qui importe/exporte toutes les fonctions publiques du dossier (pattern déjà en place dans db/__init__.py et screens/__init__.py — garde __all__ explicite à jour à chaque ajout).
|
||||
- Chaque dossier a un fichier .md qui documente chaque fonction publique qu'il contient : signature, rôle, paramètres, valeur de retour, exceptions possibles. Mets-le à jour à chaque fonction ajoutée/modifiée/supprimée.
|
||||
|
||||
### Exceptions à cette règle (ne pas séparer)
|
||||
- Le pattern sanitize_X / resolve_X (validation + résolution avec repli sur valeur par défaut) : ces deux fonctions restent dans le même fichier quand resolve_X appelle directement sanitize_X et qu'elles partagent des constantes de configuration. Ce sont deux étapes d'un même contrat, pas deux responsabilités séparées.
|
||||
- Un groupe de fonctions fortement couplées par une table de dispatch centrale et des constantes de module partagées (ex. collision_rules.py) peut rester dans un seul fichier si les séparer forcerait soit une duplication de constantes, soit un fichier de constantes partagé importé par tous les autres pour un gain de lisibilité douteux. En cas de doute, demande avant de trancher plutôt que d'appliquer la règle mécaniquement.
|
||||
- Des handlers Flask co-localisés par convention d'URL (ex. plusieurs routes d'un même sous-domaine fonctionnel) qui n'ont aucun appel ni état partagé entre eux ne sont PAS un cas d'exception — ceux-là doivent être séparés, un fichier par route/fonction.
|
||||
|
||||
### Avant de créer une nouvelle fonction
|
||||
Vérifie si elle appartient à un fichier existant à forte cohésion (voir exceptions ci-dessus) ou si elle mérite son propre fichier. En cas de doute sur la classification, demande plutôt que de deviner.
|
||||
|
||||
### Constantes partagées entre plusieurs fonctions d'un même dossier
|
||||
Si plusieurs fonctions séparées (dans des fichiers différents) ont besoin des mêmes constantes, crée un fichier constants.py dans le dossier concerné plutôt que de dupliquer les valeurs — ne duplique jamais une constante de configuration entre fichiers.
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
# Qualité de code — Forge Engine
|
||||
|
||||
Référence de fonctionnement du dispositif qualité mis en place (Phases 1
|
||||
à 4). Pour le compte-rendu chronologique de ce qui a été fait/trouvé/
|
||||
décidé pendant la mise en place, voir `docs/SESSION_RECAP.md` — ce
|
||||
fichier-ci documente uniquement **l'état actuel** et **comment
|
||||
l'utiliser au quotidien**.
|
||||
|
||||
Règle de base, non négociable : **aucune règle de lint/typage n'est
|
||||
désactivée globalement dans un fichier de config sans validation
|
||||
explicite.** Une exception ponctuelle est toujours une ligne de code
|
||||
(`# noqa`, `# nosec`, `// NOSONAR`) avec une raison précise, jamais un
|
||||
ignore de fichier entier ou de règle globale — voir section 4.
|
||||
|
||||
## 1. Vue d'ensemble
|
||||
|
||||
| Outil | Vérifie | Portée | Obligatoire / Avertissement |
|
||||
|---|---|---|---|
|
||||
| **Ruff** (lint) | Erreurs Python, imports inutilisés, style | `*.py` | Obligatoire (bloquant CI + pre-commit) |
|
||||
| **Ruff** (format) | Formatage Python | `*.py` | Obligatoire |
|
||||
| **Mypy** `--strict` | Typage statique Python | `*.py` | Obligatoire |
|
||||
| **Bandit** | Sécurité Python (injections, primitives faibles) | `ai`, `auth`, `core`, `db`, `filters`, `publish`, `routes`, `screens`, `scripts`, `app.py`, `build_css.py` | Obligatoire |
|
||||
| **Vulture** | Code mort Python | mêmes dossiers que Bandit | Obligatoire |
|
||||
| **import-linter** | Contrat de couches applicatives | tout le code Python | Obligatoire |
|
||||
| **ESLint** (`airbnb-base`) | Lint JavaScript | `static/js/**/*.js` | Obligatoire |
|
||||
| **Stylelint** (`stylelint-config-standard`) | Lint CSS | `styles/**/*.css` | Obligatoire |
|
||||
| **djLint** | Lint des templates Jinja | `templates/**/*.html` | **Avertissement** — H021 (styles inline) en backlog assumé, voir section 6 |
|
||||
| **SonarQube** (local + CI) | Bugs/vulnérabilités/code smells/hotspots agrégés | tout le dépôt | **Avertissement** — non-bloquant en CI pour l'instant, voir section 3 |
|
||||
|
||||
## 2. Configuration de chaque outil
|
||||
|
||||
### Ruff (`pyproject.toml`, `[tool.ruff]`)
|
||||
- `line-length = 120`, `target-version = "py313"`.
|
||||
- Règles activées : `F` (pyflakes), `E`/`W` (pycodestyle), `B` (bugbear), `C4` (comprehensions), `SIM` (simplification), `S` (bandit-équivalent Ruff), `I` (imports).
|
||||
- `per-file-ignores` : `S101`/`S106` désactivées pour `tests/` uniquement (assert et mots de passe en dur sont la norme dans les tests, jamais en dehors).
|
||||
- Exclusions : `projects/`, `user_assets/`, `data/`, `Bug/`, `regles/` (données runtime, jamais du code).
|
||||
|
||||
### Mypy (`pyproject.toml`, `[tool.mypy]`)
|
||||
- `strict = true`, `python_version = "3.13"`.
|
||||
- `explicit_package_bases = true` + `mypy_path = "."` : nécessaire car `tests/` n'a pas de `__init__.py` — sans ça mypy refuse de démarrer ("Source file found twice under different module names"). **Ce n'est pas un assouplissement de `strict`**, juste la résolution des chemins de module.
|
||||
- Mêmes exclusions que Ruff.
|
||||
|
||||
### Bandit (`pyproject.toml`, `[tool.bandit]`)
|
||||
- `exclude_dirs` : `projects`, `user_assets`, `data`, `Bug`, `regles`, `tests` (les tests contiennent des mots de passe/tokens de test en dur, jamais un vrai risque).
|
||||
- Portée explicite (`-r ai auth core db filters publish routes screens scripts app.py build_css.py`) plutôt que tout le dépôt.
|
||||
|
||||
### Vulture (`pyproject.toml`, `[tool.vulture]`)
|
||||
- `min_confidence = 80`, `paths` = mêmes dossiers que Bandit + `vulture_whitelist.py`.
|
||||
- `exclude = ["*/tests/*"]`.
|
||||
- Faux positifs structurels (routes Flask enregistrées par décorateur, hooks appelés par convention de nom) : whitelist dédiée dans `vulture_whitelist.py`, **jamais** un `min_confidence` abaissé globalement.
|
||||
|
||||
### import-linter (`pyproject.toml`, `[tool.importlinter]`)
|
||||
- Un seul contrat de type `layers`, du haut vers le bas : `routes` → `ai | publish | core` → `screens` → `auth | filters` → `db`.
|
||||
- Une couche ne peut importer qu'une couche strictement en dessous d'elle, jamais au-dessus, jamais une couche sœur du même niveau.
|
||||
- `app.py` (point d'entrée, pas un paquet) reste hors contrat — c'est lui qui importe `routes`/`core`, jamais l'inverse.
|
||||
|
||||
### ESLint (`.eslintrc.json`)
|
||||
- Base `airbnb-base` + plugins `unused-imports`, `unicorn`.
|
||||
- `max-len` porté à 120 (aligné sur Ruff) au lieu du 80 par défaut d'Airbnb.
|
||||
- Plusieurs règles Airbnb désactivées **après vérification individuelle que le code existant les respecte déjà différemment** (voir section 5 pour le détail et le raisonnement de chacune) : `no-param-reassign`, `no-use-before-define` (assoupli pour les fonctions, gardé strict pour variables/classes), `func-names`, `no-underscore-dangle`, `no-console`, `no-plusplus`, `no-continue`, `no-bitwise`, `guard-for-in`, `no-restricted-syntax`, `prefer-destructuring`, `consistent-return`, `no-return-assign`, `no-nested-ternary`, `no-void`, `implicit-arrow-linebreak`, `no-unused-expressions`, `no-useless-concat`.
|
||||
- `unused-imports/no-unused-vars` avec une liste blanche (`varsIgnorePattern`) d'environ 95 noms : fonctions invoquées uniquement depuis des attributs `onclick`/`onchange` inline dans les templates Jinja, invisibles pour l'analyse statique d'ESLint.
|
||||
- Bloc `globals` documentant les variables cross-fichiers volontaires (ex. `gameData`, `screensData` — voir section 5).
|
||||
- **`eslint-plugin-unicorn`** (version `55.0.0` épinglée — la dernière exige ESLint ≥10, incompatible avec notre `^8.57.1`) : activé avec **UNIQUEMENT 9 règles explicitement listées**, jamais sa config `recommended` complète (qui en contient des dizaines d'autres, jamais évaluées pour ce projet — décision délibérée pour ne pas introduire un nouveau volume de règles non passées en revue). Choisi pour nettoyer le lot "modernisation JS" du rapport SonarQube (voir `docs/JS_MODERNIZATION_PLAN.md`) : `eslint-plugin-sonarjs` (l'équivalent officiel SonarSource) ne couvrait qu'une seule des règles visées. Les 9 règles activées, chacune avec un fixer `--fix` vérifié :
|
||||
- `unicorn/prefer-number-properties` — `parseFloat`/`parseInt`/`isNaN`/`isFinite` → `Number.*`
|
||||
- `unicorn/prefer-string-replace-all` — `.replace(/x/g, ...)` → `.replaceAll(...)`
|
||||
- `unicorn/prefer-dom-node-dataset` — `getAttribute('data-x')` → `.dataset.x`
|
||||
- `unicorn/prefer-includes` — `.indexOf(x) !== -1` → `.includes(x)`
|
||||
- `unicorn/prefer-string-starts-ends-with` — comparaison manuelle de sous-chaîne → `.startsWith()`/`.endsWith()`
|
||||
- `unicorn/prefer-modern-math-apis` — expression mathématique manuelle → `Math.hypot()` etc.
|
||||
- `unicorn/prefer-at` — `arr[arr.length - 1]` → `arr.at(-1)`
|
||||
- `unicorn/no-useless-fallback-in-spread` — `{...(x || {})}` → `{...x}`
|
||||
- `unicorn/no-for-loop` — boucle `for` classique sur un itérable → `for...of`
|
||||
|
||||
### Stylelint (`.stylelintrc.json`)
|
||||
- Base `stylelint-config-standard`.
|
||||
- `selector-class-pattern`/`selector-id-pattern` désactivées : le projet utilise du camelCase pour ses classes/id CSS depuis le début (`.canvasElement`, `#scormProgress`...) — imposer le kebab-case du preset aurait demandé de renommer des milliers de sélecteurs et leurs usages JS/HTML pour un gain nul.
|
||||
- `no-descending-specificity` désactivée : le CSS existant est organisé par composant/fonctionnalité, pas par ordre strict de spécificité.
|
||||
- `ignoreFiles` : `static/vendor/**`, `static/style.css` (bundle généré, contient du Bulma vendored).
|
||||
|
||||
### djLint (`pyproject.toml`, `[tool.djlint]`)
|
||||
- `profile = "jinja"`, `max_line_length = 160`, `indent = 2`.
|
||||
- H021 (styles inline) : **backlog assumé, pas une exception corrigée** — voir section 6, ne pas confondre avec les vraies exceptions de la section 5.
|
||||
|
||||
### SonarQube
|
||||
- **CI (`sonar.forgebase.fr`)** : job `sonarqube` (`.gitea/workflows/deploy.yml`), scan à chaque push, **non-bloquant** (`continue-on-error: true`) le temps que le rapport soit entièrement trié. Retiré le 18/09/2026 (voir historique ci-dessous), remis en place le 18/09/2026 une fois l'instance prod confirmée opérationnelle.
|
||||
- **Service prod** (`docker-compose.prod.yml`) : reste retiré côté dépôt pour l'instant — l'instance prod opérationnelle n'est pas (encore) pilotée par ce fichier de ce côté-ci ; à réintégrer explicitement si besoin.
|
||||
- **Historique du retrait temporaire (16-18/09/2026)** : l'accès au dashboard (local WSL2 et prod derrière Caddy) était resté bloqué par des soucis d'infrastructure réseau (redirection de port WSL2/pare-feu Hyper-V côté local, réseau Docker partagé avec Caddy pas encore en place côté prod à ce moment) sans lien avec le code du moteur. Le lot "modernisation JS" (voir `docs/JS_MODERNIZATION_PLAN.md`) s'était arrêté après le lot 3 (`S2486`) pour cette même raison — les lots 4+ dépendent de scores Sonar exacts (complexité cognitive notamment) qu'aucun proxy fiable ne remplace parfaitement (le SonarLint de l'IDE a servi de solution de contournement fonctionnelle en attendant, voir lots 4 en cours directement sur les fichiers ouverts dans l'éditeur).
|
||||
|
||||
## 3. Lancer les checks en local
|
||||
|
||||
```bash
|
||||
# Un outil a la fois (memes commandes que .pre-commit-config.yaml)
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
mypy .
|
||||
vulture
|
||||
bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py
|
||||
lint-imports
|
||||
djlint templates
|
||||
npx eslint "static/js/**/*.js"
|
||||
npx stylelint "styles/**/*.css"
|
||||
|
||||
# Tout d'un coup (ce que fait un commit)
|
||||
pre-commit run --all-files
|
||||
|
||||
# Suite de tests
|
||||
python -m pytest tests/ -q
|
||||
node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
|
||||
```
|
||||
|
||||
SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloquante), instance locale toujours bloquée (voir historique dans cette même sous-section).
|
||||
|
||||
## 4. Procédure de justification d'une exception
|
||||
|
||||
1. **Jamais de correction mécanique sans comprendre la cause.** Avant de corriger un finding, déterminer s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur, architecture volontaire).
|
||||
2. **Une exception ponctuelle, jamais globale.** Le commentaire de suppression va **sur la ligne exacte** signalée par l'outil — pas la ligne au-dessus, pas la ligne en dessous (piège vécu à plusieurs reprises cette session : un commentaire mal placé ne supprime rien du tout, silencieusement).
|
||||
3. **La raison est précise, jamais vague.** "Faux positif" seul ne suffit pas — expliquer *pourquoi* (ex. "table_name vient de slugify()+prefixe obj_, jamais d'une entrée brute").
|
||||
4. **Format attendu par outil** (vérifié empiriquement cette session, chaque outil a ses propres exigences de position du mot-clé) :
|
||||
- Bandit : `# nosec <CODE>` — le mot `nosec` doit être immédiatement précédé d'un `#` sur la ligne.
|
||||
- Ruff : `# noqa: <CODE>` — le mot `noqa` doit être immédiatement précédé d'un `#` sur la ligne. **Bandit et Ruff peuvent coexister sur une même ligne physique** en utilisant deux `#` distincts (`# nosec B101 # noqa: S101 - raison`) — c'est structurel, pas un choix de style : chaque outil cherche son propre mot-clé juste après un `#`, et aucun format à un seul `#` ne peut satisfaire les deux à la fois (testé empiriquement).
|
||||
- SonarQube (Python) : `# NOSONAR <règle> - raison`, sur la ligne exacte.
|
||||
- SonarQube (JS) : `// NOSONAR <règle> - raison`, sur la ligne exacte.
|
||||
- SonarQube (templates Jinja/HTML, règle `Web:*`) : **aucune syntaxe trouvée qui fonctionne** malgré plusieurs tentatives (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) — voir la limitation documentée en section 5 (`Web:S5247`).
|
||||
5. **Toute exception validée est répercutée ici** (section 5), avec la date/le contexte. Une exception non documentée ici n'est pas considérée comme validée.
|
||||
6. **Qui valide** : aucune exception n'est appliquée sans validation explicite de l'utilisateur — présenter le choix avec ses compromis, jamais trancher seul quand plusieurs options légitimes existent.
|
||||
|
||||
## 5. Exceptions et faux positifs documentés
|
||||
|
||||
| Site(s) | Outil / règle | Raison | Contexte |
|
||||
|---|---|---|---|
|
||||
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
|
||||
| `screens/data_actions/compute_operation.py` (×2), `static/js/play/offline/compute-operation.js` (×2), `static/js/scenes/collision-rules-editor.js` (×2), `static/js/triggers/trigger-editor.js` | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI) — jamais un usage cryptographique. | Phase 3 |
|
||||
| `static/js/play/offline/xapi-client.js` (18 sites) + `static/js/play/offline/__tests__/xapi-client.test.js` (2 sites) | `javascript:S5332` | Identifiants du vocabulaire xAPI standard ADL (`http://adlnet.gov/expapi/...`), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le `http://` fait partie du texte fixé par la spec. Le vrai endpoint réseau (`config.endpoint`) est toujours saisi par le créateur, jamais un littéral de ce fichier. | Phase 3 |
|
||||
| `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 |
|
||||
| `scripts/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 |
|
||||
| `static/js/scenes/collision-rules-editor.js` (`leafAction.then = nextLeaf`) | `javascript:S7739` | `then` est un champ métier ("action suivante de la chaîne"), jamais une promesse — vérifié qu'aucun site d'appel ne le passe à `await`/`Promise.resolve()`. Risque latent documenté plutôt que renommage (le nom est ancré dans le schéma JSON persisté en base et côté Python). | Phase 3 |
|
||||
| `db/assert_not_none.py:14` | `B101`/`S101`/`python:S7632` | Unique `assert` de narrowing de type restant dans tout le moteur, après centralisation de 11 sites dispersés (`ai/`, `routes/scenes/`, `screens/payload/`, `scripts/`) dans ce helper unique. Voir section 4 pour l'impossibilité structurelle de satisfaire Bandit+Ruff avec un seul `#`. | Session du 16/09/2026 |
|
||||
| `db/rows/delete_row.py`, `db/rows/get_row.py`, `db/rows/list_rows.py`, `screens/animations/update_animation_clip.py`, `screens/flow/add_flow_node.py`, `screens/scenes/add_scene_object.py` | `B608`/`S608`/`python:S7632` | Noms de table/colonnes construits uniquement à partir de `slugify()`/whitelists codées en dur (`_UPDATABLE_FIELDS`, `FLOW_NODE_FIELDS`), jamais d'une entrée arbitraire — valeurs toujours paramétrées (`?`). Distinct du cas `assert_not_none` ci-dessus (nature différente : construction de SQL, pas narrowing de type) — non couvert par ce refactor. | Session du 16/09/2026 |
|
||||
| `static/js/play/bindings.js:179` (`gameData = newData`) | `javascript:S2703` | Pattern volontaire de scripts globaux (pas des modules ES) : `gameData`/`screensData` sont déclarés une fois par `let` dans le `<script>` inline de `templates/play.html`, chargé avant tous les `static/js/play/*.js` (ordre séquentiel vérifié, pas de `defer`/`async`). Réassignation légitime d'une variable déjà déclarée dans un scope global partagé — déjà documenté dans `.eslintrc.json` (`"gameData": "writable"`). | Session du 16/09/2026 |
|
||||
| `static/js/play/bindings.js:180` (`screensData = gameData.screens`) | `javascript:S2703` | Même bloc, même pattern, même fichier de déclaration que `gameData` ci-dessus : `let screensData = gameData.screens;` posé dans le même `<script>` inline de `templates/play.html:145` (juste après `gameData:144`), chargé avant `bindings.js` — déjà documenté dans `.eslintrc.json` (`"screensData": "writable"`). Lot 2 "modernisation JS". | Lot 2 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations dans ce fichier, **et** `static/js/triggers/trigger-editor.js:787,937` (`triggerOpenAppendActionModal`/`screenTriggerOpenAppendActionModal`, `_collisionWizard = { bodyEl: body };` sans mot-clé) | `javascript:S2703` | Même pattern de partage inter-scripts que `SCENE_OBJECT_NAMES` : `trigger-editor.js` réutilise TELLES QUELLES les étapes de l'assistant de `collision-rules-editor.js` (`renderCollisionWizardChainStep`/`collisionWizardBuildLeafAction`, qui ne lisent que `.bodyEl` — voir commentaire ligne 784-786 de `trigger-editor.js`) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. `trigger-editor.js` est chargé AVANT `collision-rules-editor.js` (`templates/scene_edit.html:887-888`), mais sans risque de TDZ : les deux réassignations de `trigger-editor.js` sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que `collision-rules-editor.js` (et son `let _collisionWizard = null;`) n'ait fini de se charger. Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026 |
|
||||
| `static/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 |
|
||||
| 31 sites `\|safe` (`templates/scene_edit.html`, `templates/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces 31 sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 |
|
||||
| `static/js/play/offline/filter-repeater-rows.js:10,11`, `static/js/screen_edit/panel-init.js:254,261`, `static/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/dialogue-box-controller.js` (`forgeShowQuizBox`, ligne du `void widget.offsetWidth;`) | `javascript:S3735` | Force une lecture de mise en page (reflow) AVANT de reposer la classe `is-active`, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, `void` marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : `void widget.offsetWidth;` (S3735, "retirer void") → `widget.offsetWidth;` seule (S905, "expression sans effet — accepté par ESLint ici, `no-unused-expressions` est désactivé dans ce projet, mais pas par Sonar") → `const _ = widget.offsetWidth;` (S1481, "variable jamais lue — accepté par ESLint via `varsIgnorePattern: ^_$`, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : `void` restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. | Lot 4 "modernisation JS", 18/09/2026 |
|
||||
| `static/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/screen_edit/tree-panels.js:162` (`dragstart` galerie d'icônes), `:295` (`dragstart` arborescence) | `javascript:S2486` | `e.dataTransfer.setData(...)` sert uniquement à satisfaire l'exigence cross-navigateur de l'API HTML5 Drag (au moins un type MIME posé) — vérifié que les deux `drop` correspondants (lignes 177-182 et 320-344) lisent l'id glissé depuis une variable JS module (`draggedIconClass`/`treeDragElementId`), jamais `e.dataTransfer.getData(...)` : un échec de `setData` n'a donc aucun effet sur le comportement réel du glisser-déposer. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/offline/xapi-client.js:72` (`forgeXapiActor`) | `javascript:S2486` | API SCORM absente ou non conforme — attendu hors d'un vrai LMS (ex. prévisualisation) — repli déjà en place sur un acteur anonyme générique. Comportement déjà documenté par le commentaire du bloc. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/screens.js:89` (`runScreenBackgroundMusic`), `static/js/play/actions.js:333` (action "jouer_son") | `javascript:S2486` | `.play().catch(() => {})` — échec attendu du navigateur (lecture audio automatique bloquée tant qu'aucune interaction utilisateur n'a eu lieu), jamais une erreur applicative à signaler. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/screens.js:192` (`applyAnimationClip`, clip `sprite`), `static/js/play/actions.js:340` (action `jouer_animation_sprite`) | `javascript:S2486` | **Corrigé, pas seulement documenté** : `JSON.parse(custom_keyframes / data_value)` invalide laissait `spriteData` retomber silencieusement sur `{}` (aucune animation jouée) sans aucun signal — `custom_keyframes`/`data_value` sont produits par l'éditeur, jamais tapés à la main, donc un JSON invalide ici trahit presque toujours un bug côté éditeur plutôt qu'une simple erreur de saisie. Un `console.warn('configuration sprite invalide', e)` a été ajouté dans les deux `catch` : signal devtools pour le créateur en test, **comportement joueur inchangé** (l'animation reste silencieusement absente). Couvert par un nouveau test (`static/js/play/__tests__/actions.test.js`, `runActionNode — jouer_animation_sprite avec data_value JSON invalide`) qui vérifie à la fois l'absence de crash/rendu cassé ET l'appel du `console.warn`. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 |
|
||||
| `static/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 |
|
||||
|
||||
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
|
||||
|
||||
Méthode : pour chaque regex distincte flaguée, construction d'entrées **adversariales** (choisies pour maximiser l'ambiguïté que Sonar soupçonne) via un script Node.js dédié, mesure du temps d'exécution à plusieurs tailles croissantes. Un vrai ReDoS montre une croissance **exponentielle** du temps avec la taille de l'entrée (doubler la taille multiplie le temps par un facteur, pas par une constante) — ici, le temps reste quasi-linéaire à toutes les tailles testées, y compris pour la structure la plus suspecte des 3.
|
||||
|
||||
**Pattern A** — `FORGE_REF_PATTERN` (`filter-repeater-rows.js:10`) et `_FILTER_REF_RE` (`panel-init.js:254`, copie identique) :
|
||||
```
|
||||
/^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/
|
||||
```
|
||||
Structure soupçonnée : deux groupes quantifiés (`[^.{}]+`) séparés par un littéral (`\.`). Non exploitable ici car les deux classes sont **négatives** et excluent explicitement le `.` qui les sépare — à une position donnée, il n'existe qu'un seul découpage possible entre les deux groupes (pas de chevauchement combinatoire).
|
||||
Entrées testées : `` `{{` + 'a'.repeat(n) `` (pas de fermeture) et `` `{{` + 'a'.repeat(n) + '.' + 'b'.repeat(n) `` (point présent, pas de fermeture), `n` = 1 000 / 10 000 / 50 000 / 100 000.
|
||||
Résultat mesuré : **1.39 ms à n=100 000** (pire cas). Vérifié aussi que le pattern reste correct sur une entrée valide (`{{Objet.champ}}` → capture `['Objet', 'champ']`).
|
||||
|
||||
**Pattern B** — `FORGE_VAR_REF_PATTERN` (`filter-repeater-rows.js:11`) et `_VAR_REF_RE` (`panel-init.js:261`, copie identique) :
|
||||
```
|
||||
/^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/
|
||||
```
|
||||
Structure soupçonnée : la plus proche d'un vrai ReDoS des 3 — un groupe répété par `*` dont une branche de l'alternance contient elle-même un `+` (proche du classique `(a+)*`). Non exploitable ici car chaque itération exige un caractère de tête exclusif (`.` ou `[`) qui est justement exclu de la classe négative interne (`[^.{}[\]]`) — aucune itération ne peut chevaucher la suivante.
|
||||
Entrées testées : `` `{{$a` + '.b'.repeat(n) `` (répétition simple, pas de fermeture) et `` `{{$a` + '.b[0]'.repeat(n) `` (alternance des deux branches, pas de fermeture), `n` = 1 000 / 5 000 / 10 000 / 20 000.
|
||||
Résultat mesuré : **0.95 ms à n=20 000 segments** (pire cas, forme alternée). Vérifié sur entrée valide (`{{$var.champ[0].sous}}` → capture `['var', '.champ[0].sous']`).
|
||||
|
||||
**Pattern C** — `xapi-client.js:132` :
|
||||
```js
|
||||
config.endpoint.replace(/\/+$/, '')
|
||||
```
|
||||
Structure soupçonnée : un seul groupe quantifié sur un littéral unique, ancré en fin de chaîne — le cas le plus simple des 3, sans groupe adjacent ni alternance avec qui entrer en ambiguïté.
|
||||
Entrée testée : `'/'.repeat(n)`, `n` = 10 000 / 100 000 / 1 000 000.
|
||||
Résultat mesuré : **1.38 ms à n=1 000 000** (le run à n=100 000 a affiché 13.89 ms, un pic de bruit de mesure — non reproductible et incohérent avec un temps plus court à n=1 000 000, donc pas un signal réel). Vérifié sur entrée valide (`'https://host///'.replace(...)` → `'https://host'`).
|
||||
|
||||
**Pour refaire ce test après une modification d'une de ces 3 regex** : `node docs/redos_probe_s8786.js` — script conservé dans le dépôt, directement exécutable, pas à reconstituer depuis la prose. Si le temps croît plus vite que linéairement (ex. ×100 quand la taille ×10), c'est un vrai ReDoS — sinon, le `NOSONAR` reste justifié.
|
||||
|
||||
## 6. Backlog qualité
|
||||
|
||||
- **djLint H021 (49 occurrences, 6 templates)** — styles inline à remplacer par un système de modèles/classes CSS réutilisables. **Priorité immédiate** après la clôture de ce dispositif, avant ou après la Phase 4 CI selon décision à prendre le moment venu. Ce n'est **pas** une exception documentée en section 5 : c'est une dette assumée et non traitée, à corriger, pas à justifier indéfiniment.
|
||||
- **Code smells SonarQube** — lot JS "modernisation" (357 issues d'origine), plan détaillé et validé dans `docs/JS_MODERNIZATION_PLAN.md` (répartition par règle, classification mécanique/cas par cas, outillage vérifié, 9 lots) ; lots 1-3 (`S8786`, `S2703`, `S2486`) faits. Lots 4+ repris le 18/09/2026 via SonarLint (IDE) fichier par fichier — pas de liste exhaustive centralisée pendant que le dashboard serveur restait inaccessible, donc pas de rescan global de confirmation avant que la CI `sonarqube` (voir ci-dessous) tourne à nouveau. Reste aussi la complexité cognitive Python et la duplication, non encore triées.
|
||||
- **CI `sonarqube` restaurée (18/09/2026)** — job remis en place dans `.gitea/workflows/deploy.yml` (non-bloquant) une fois l'instance prod confirmée opérationnelle. Le service `docker-compose.prod.yml` reste volontairement absent de ce dépôt (prod pilotée autrement de ce côté). Une fois le premier scan CI passé : comparer son rapport à ce qui a déjà été corrigé fichier par fichier via SonarLint, pour confirmer qu'aucun doublon/oubli, avant de retirer `continue-on-error: true`.
|
||||
@@ -1,748 +1,143 @@
|
||||
# Forge Engine — prototype (lot de fonctionnalités n°1)
|
||||
# Forge Engine
|
||||
|
||||
Outil no-code : créer des jeux, définir leurs objets de données (comme des
|
||||
tables de base de données, avec des champs typés et des relations entre
|
||||
eux), et remplir ces données via des formulaires générés automatiquement.
|
||||
Éditeur no-code de serious games 2D pour la formation professionnelle,
|
||||
dans le navigateur. Créé pour un public **non technique** (formateurs,
|
||||
RH) : aucune ligne de code, aucun graphe de logique générique — un
|
||||
assistant pas à pas pour chaque mécanique de jeu.
|
||||
|
||||
Python + SQLite + Flask. Chaque jeu créé a sa propre base de données réelle
|
||||
(fichier `.db`), pas de simulation : définir un objet exécute un vrai
|
||||
`CREATE TABLE`, remplir le formulaire exécute un vrai `INSERT`.
|
||||
> Voir `.claude/Forge_Engine_Cadrage.pdf` (non versionné, document
|
||||
> business interne) pour le positionnement produit complet et l'analyse
|
||||
> de marché qui a guidé les choix ci-dessous.
|
||||
|
||||
## Lancer (pour tester maintenant, en ligne de commande)
|
||||
## Lancer le projet
|
||||
|
||||
```bash
|
||||
cd forge-engine
|
||||
pip install -r requirements.txt
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
Le navigateur s'ouvre automatiquement sur http://127.0.0.1:5050.
|
||||
|
||||
*(Ceci est la version de développement. Comme convenu, une version finale
|
||||
empaquetée — double-clic, sans terminal ni installation — sera produite une
|
||||
fois que toutes les fonctionnalités prévues auront été ajoutées.)*
|
||||
## Ce que permet Forge Engine aujourd'hui
|
||||
|
||||
## Ce qui est construit dans ce lot
|
||||
Chaque jeu créé est un **RPG 2D** : une ou plusieurs scènes (des décors à
|
||||
taille fixe), sur lesquelles on pose des personnages, un fond, et des
|
||||
widgets d'interface — tout au glisser-déposer, sans jamais toucher à du
|
||||
HTML/CSS/JS.
|
||||
|
||||
1. **Créer un jeu** : bouton sur la page d'accueil → crée
|
||||
`projects/<nom-du-jeu>/index.html` + `index.css` + `index.js` (reliés
|
||||
entre eux), et `projects/<nom-du-jeu>/game.db` (base SQLite dédiée).
|
||||
2. **Définir un objet** (comme une table de base de données) : nom + liste
|
||||
de champs typés (texte, texte long, nombre entier, nombre décimal,
|
||||
oui/non, date, relation vers un autre objet déjà défini dans ce jeu).
|
||||
Chaque objet défini devient une vraie table SQL (`obj_<nom>`), avec les
|
||||
bonnes colonnes et, pour les relations, une vraie clé étrangère.
|
||||
3. **Formulaire généré automatiquement** : une fois un objet défini, le
|
||||
moteur lit sa définition et construit lui-même le formulaire de saisie
|
||||
adapté (bon type de champ HTML selon le type choisi, menu déroulant
|
||||
peuplé avec les entrées existantes pour les relations). Valider le
|
||||
formulaire enregistre une vraie ligne dans la table SQL correspondante.
|
||||
4. **CRUD complet** sur les trois niveaux :
|
||||
- **Jeu** : renommer, supprimer (dossier + base de données).
|
||||
- **Objet** (page "✏️ Modifier cet objet") : renommer, ajouter un champ
|
||||
(vrai `ALTER TABLE ... ADD COLUMN`), retirer un champ (vrai
|
||||
`ALTER TABLE ... DROP COLUMN`), supprimer l'objet entier (vrai
|
||||
`DROP TABLE`) — **bloqué** si un autre objet a une relation vers
|
||||
celui-ci, avec indication de quel(s) objet(s).
|
||||
- **Donnée** (une ligne) : modifier, supprimer — **bloqué** si une autre
|
||||
ligne pointe vers elle via une relation, avec indication de combien et
|
||||
depuis quel objet.
|
||||
- **Personnages** : bibliothèque de sprites prêts à l'emploi (personnages
|
||||
Kenney CC0 + animaux CraftPix pour les comptes admin), animés
|
||||
automatiquement (idle, marche...). Un rôle — **Joueur** (déplacement au
|
||||
clavier, suivi par la caméra), **PNJ** ou **Ennemi** — se choisit
|
||||
explicitement par personnage ; un personnage fraîchement posé est
|
||||
toujours "PNJ" par défaut.
|
||||
- **Collision** (onglet dédié de l'éditeur de scène) : pour chaque objet
|
||||
de la scène (jamais le joueur lui-même), un assistant "+ Action"
|
||||
construit une règle "déclencheur → action" pièce par pièce (jamais un
|
||||
formulaire à plusieurs champs) — à la collision ou dans un périmètre,
|
||||
déclencher une quête, ou une interaction au clavier ("Appuie sur ...")
|
||||
qui elle-même déclenche une quête. Toute la liste se met à jour en
|
||||
AJAX (ajout/suppression d'objet, changement de rôle/nom), sans jamais
|
||||
recharger la page.
|
||||
- **Quêtes & dialogues** : chaque quête a un titre, un objectif, une
|
||||
récompense, un statut et un résultat ; son dialogue se rédige
|
||||
séparément (3 colonnes — Nouvelle / En cours / Terminée — pour faire
|
||||
varier ce que dit un PNJ selon l'avancement).
|
||||
- **Widgets d'interface** : Boîte de dialogue, Boîte à quiz, Score — posés
|
||||
comme n'importe quel objet de scène, gérés automatiquement par le
|
||||
moteur de jeu (jamais de logique à câbler), avec leur propre style
|
||||
(police, couleurs) réglable une fois posés.
|
||||
- **Export SCORM 1.2** natif (`routes/publish/export_scorm.py`) : un
|
||||
paquet .zip autonome (imsmanifest.xml + runtime hors-ligne), prêt à
|
||||
déposer dans un LMS (Moodle, 360Learning...) — score et statut de la
|
||||
partie remontent automatiquement.
|
||||
|
||||
5. **Écrans de jeu** (`🖥️ Écrans` depuis le tableau de bord d'un jeu) —
|
||||
éditeur façon Elementor/Bricks Builder, pensé pour quelqu'un qui ne
|
||||
connaît ni le HTML ni le CSS :
|
||||
- **3 panneaux** : à gauche, une grille d'icônes pour ajouter un élément
|
||||
— aucun nom de balise HTML visible nulle part. Au centre, l'écran en
|
||||
très grand (repères Portrait / Paysage / Carré, juste visuels — l'écran
|
||||
réel du joueur reste toujours responsive). À droite, les propriétés de
|
||||
l'élément sélectionné, groupées par thème et repliables comme un
|
||||
accordéon (Position & taille, Contenu, Texte, Disposition, Espacement,
|
||||
Bordure, Actions...) — un seul groupe utile ouvert par défaut, les
|
||||
autres se déplient d'un clic sur leur titre, pour garder le panneau
|
||||
lisible même avec beaucoup de réglages.
|
||||
- **20 types d'éléments** : Texte, Titre, Bouton, Lien, Image (URL ou
|
||||
fichier envoyé depuis ton ordinateur — l'envoi enregistre aussitôt
|
||||
l'image, sans clic supplémentaire à faire pour la voir apparaître),
|
||||
Vidéo (idem), Champ de
|
||||
formulaire — texte / email / mot de passe, Case à cocher, Bouton radio,
|
||||
Zone de texte, Liste déroulante, Groupe de champs, Liste à puces, Liste
|
||||
numérotée, Tableau, Conteneur / décor, Séparateur, Élément de jeu du
|
||||
catalogue, et le **Répéteur de données** (voir plus bas).
|
||||
- **Un réglage = un seul champ adapté** : case à cocher pour "gras" ou
|
||||
"obligatoire", curseur pour une taille/un arrondi/un espacement, pastille
|
||||
de couleur, boutons ⬅️/⬛/➡️/☰ pour l'alignement — jamais de nom de
|
||||
propriété CSS à taper. Chaque élément dispose maintenant, en plus de ses
|
||||
réglages propres, d'un socle commun : **visibilité** (visible / masqué /
|
||||
invisible-mais-garde-sa-place), **espacement** (marge intérieure et
|
||||
extérieure), **bordure** (épaisseur, couleur, style), et pour les
|
||||
conteneurs/listes/tableaux, une **disposition interne** façon flexbox
|
||||
(empilement libre / en ligne / en colonne, avec l'espacement entre
|
||||
éléments). Les textes ont en plus gras, italique, souligné, barré et un
|
||||
choix de police (polices système + quelques Google Fonts).
|
||||
- **Position & taille visibles et modifiables** : 4 champs (X, Y,
|
||||
Largeur, Hauteur, en %) dans le panneau de droite, synchronisés avec le
|
||||
glisser-déposer sur l'écran.
|
||||
- **Glisser-déposer et redimensionnement à la souris**, exactement comme
|
||||
avant : on peut vraiment attraper un élément et le faire glisser sur
|
||||
l'écran (un simple clic sans glisser le sélectionne juste). Sans jamais
|
||||
toucher à un fichier HTML/CSS : tout est stocké en base et généré à la
|
||||
volée.
|
||||
- **ID unique par élément** : chaque élément affiche son identifiant
|
||||
(`elt-<numéro>`) dans ses propriétés — attribué automatiquement, garanti
|
||||
unique dans le jeu.
|
||||
- **Enchaînement des écrans** : liste ordonnée (premier écran, deuxième
|
||||
écran...), réorganisable (↑ / ↓).
|
||||
- **🔀 Logique de la scène (éditeur de flow à nœuds)** : la logique d'un
|
||||
écran (ce qui se passe au clic ou à la soumission d'un formulaire) ne
|
||||
se règle plus dans les propriétés de chaque élément, mais dans un
|
||||
panneau dédié, rétractable, en bas de l'éditeur d'écran — un vrai
|
||||
canevas où l'on pose des **nœuds** et où on les relie par des fils,
|
||||
dans l'esprit de Salesforce Flow ou d'Unity Visual Scripting, mais
|
||||
appliqué directement aux éléments et aux objets de données déjà
|
||||
définis dans le jeu :
|
||||
- **Déclencheur** (contour bleu) : "Au clic sur..." ou "À la
|
||||
soumission de..." un élément de l'écran — c'est le point de départ
|
||||
du graphe.
|
||||
- **Condition** (contour orange) : compare un champ d'une ligne d'un
|
||||
objet à une valeur (égal, différent, supérieur, inférieur...), et
|
||||
propose deux fils de sortie, **Vrai** et **Faux**, à relier chacun à
|
||||
la suite du graphe qui doit s'exécuter dans ce cas.
|
||||
- **Action** (contour vert) : les mêmes effets qu'avant — changer
|
||||
d'écran, modifier un élément (visibilité, couleurs, taille), ou
|
||||
modifier une donnée d'un objet (texte, nombre, Vrai/Faux, bascule,
|
||||
+/- un montant).
|
||||
Pour relier deux nœuds : clique le point de sortie du premier (côté
|
||||
droit), puis le point d'entrée du second (côté gauche) — un fil
|
||||
apparaît. Reclique un fil pour le supprimer. Chaque nœud peut être
|
||||
glissé sur le canevas pour organiser le graphe, et supprimé avec sa
|
||||
petite croix. Une action "modifier une donnée" met à jour la vraie
|
||||
base de données du jeu, et tout Répéteur de données affichant cet
|
||||
objet à l'écran se met aussitôt à jour tout seul, sans recharger la
|
||||
page.
|
||||
**Plusieurs actions pour un même déclencheur** : le point de sortie
|
||||
d'un nœud (Déclencheur, ou une branche Vrai/Faux d'une Condition) peut
|
||||
être relié à PLUSIEURS nœuds suivants — au clic, tous s'exécutent (pas
|
||||
seulement le premier relié).
|
||||
### Onboarding
|
||||
|
||||
**Effet "va-et-vient" (état à deux positions)** : pour une action
|
||||
"Modifier un élément" sur une couleur, une largeur ou une hauteur, une
|
||||
case à cocher "Va-et-vient" fait apparaître une deuxième valeur — le
|
||||
1er clic applique la 2de valeur, le 2e clic revient à la 1re, et ainsi
|
||||
de suite indéfiniment (par exemple : couleur de fond blanche au
|
||||
départ, bleue au clic, blanche au reclic...). Cet état est mémorisé
|
||||
uniquement dans la page du joueur (pas en base) : il repart de zéro si
|
||||
l'écran est rechargé.
|
||||
Un seul parcours de création à l'inscription : **RPG**. La carte de choix
|
||||
(`templates/onboarding/onboarding_new.html`) se retourne pour présenter
|
||||
l'argumentaire produit.
|
||||
|
||||
**Limites connues de cette première version** : un nœud ne peut pas
|
||||
être modifié une fois créé (il faut le supprimer et en recréer un avec
|
||||
les bons réglages) ; une condition compare toujours une ligne précise
|
||||
d'un objet choisie à la création du nœud (pas encore "la ligne sur
|
||||
laquelle le joueur vient de cliquer" dans un Répéteur) ; il n'y a pas
|
||||
encore de nœud "ET / OU" pour combiner plusieurs conditions avant un
|
||||
branchement ; l'effet "va-et-vient" ne survit pas à un rechargement de
|
||||
page (pour un état qui doit être sauvegardé, utiliser plutôt une
|
||||
Condition sur un champ Oui/Non d'un objet, combinée à une action
|
||||
"Modifier une donnée" en "Basculer Vrai/Faux").
|
||||
- **Mise en page de l'éditeur d'écran, revue** : la page ne défile plus
|
||||
jamais elle-même — elle occupe exactement la hauteur de la fenêtre.
|
||||
Les trois panneaux (ajout d'éléments, écran, propriétés) s'arrêtent
|
||||
tous au même niveau en bas, et défilent chacun pour soi si leur
|
||||
contenu dépasse (la liste d'éléments, l'écran lui-même en orientation
|
||||
portrait sur un grand écran, etc.). La grille "Ajouter un élément" est
|
||||
désormais repliable (clique son titre) pour libérer de la place. Le
|
||||
panneau "🔀 Logique de la scène", une fois ouvert, partage la hauteur
|
||||
de la fenêtre avec le reste au lieu de s'afficher par-dessus : une
|
||||
poignée juste au-dessus de son titre permet de régler sa hauteur à la
|
||||
souris (glisser vers le haut pour l'agrandir, vers le bas pour le
|
||||
réduire).
|
||||
- **Répéteur de données** : relie un élément à un objet défini dans ce jeu
|
||||
(voir "Définir un objet" plus haut) et affiche automatiquement une ligne
|
||||
par donnée existante, à partir d'un modèle de texte avec des
|
||||
`{{nom_du_champ}}` — par exemple, un objet "Niveau" avec des champs
|
||||
"nom" et "description" peut s'afficher comme `{{nom}} — {{description}}`
|
||||
répété pour chaque niveau existant, sans rien recopier à la main. La
|
||||
disposition interne (empilement/ligne/colonne + espacement) s'applique
|
||||
à la liste générée. Réglage "Modèle de ligne" : au lieu du modèle de
|
||||
texte, on peut choisir un élément de jeu du catalogue — chaque ligne
|
||||
est alors affichée avec sa mise en forme complète (voir "Éléments de
|
||||
jeu" ci-dessous) plutôt qu'en texte brut.
|
||||
- **Imbrication réelle d'éléments** : un Conteneur / décor, un Répéteur de
|
||||
données ou un Groupe de champs peut désormais contenir d'autres
|
||||
éléments *physiquement* — texte, image, bouton, un autre conteneur...
|
||||
posés dedans plutôt qu'à côté. Dans le panneau de droite, un élément de
|
||||
ce type affiche une section "Contenu du conteneur" : la liste de ce
|
||||
qu'il contient déjà, et la même grille d'icônes que "Ajouter un
|
||||
élément" pour y placer un nouvel élément. Comme ces éléments deviennent
|
||||
de vrais enfants dans la page générée, la disposition interne (ligne /
|
||||
colonne), l'espacement intérieur (padding) et la bordure du parent
|
||||
s'appliquent réellement à eux — exactement comme le ferait une vraie
|
||||
mise en page flexbox. Un élément posé à l'intérieur d'un Répéteur de
|
||||
données reçoit lui aussi les `{{nom_du_champ}}` de la ligne en cours :
|
||||
il se répète donc avec le reste. L'imbrication peut se faire sur
|
||||
plusieurs niveaux (un conteneur dans un conteneur, etc.). Un élément
|
||||
posé à l'intérieur d'un autre garde sa hauteur naturelle (comme sur une
|
||||
vraie page web) au lieu de forcer 100% de la hauteur du parent — sinon
|
||||
un deuxième élément ajouté dans le même conteneur se retrouvait poussé
|
||||
hors de la zone visible et disparaissait.
|
||||
- **Échelle (zoom)** : un nouveau réglage curseur, disponible sur tous les
|
||||
éléments (dans "Disposition"), pour agrandir ou réduire un élément sans
|
||||
toucher à sa largeur/hauteur — utile par exemple pour un effet de
|
||||
zoom au survol ou une icône plus petite que sa zone cliquable.
|
||||
- **Disposition interne complète** (conteneurs, répéteurs, listes,
|
||||
tableaux, groupes de champs) : en plus de ligne/colonne, les variantes
|
||||
inversées (ordre inversé), l'alignement transversal (étirés / au
|
||||
début / centrés / à la fin), la répartition dans le sens de la
|
||||
disposition (au début / au centre / à la fin / espacement égal entre,
|
||||
autour, ou uniforme) et une case "autoriser le retour à la ligne" —
|
||||
de quoi retrouver, avec des mots simples, tout ce qu'une vraie
|
||||
disposition flexbox permet de régler.
|
||||
- **Taille dans le conteneur** (nouveau réglage "Taille dans le
|
||||
conteneur", disponible sur tous les éléments) : deux curseurs Largeur /
|
||||
Hauteur en pixels, à 0 par défaut (comportement automatique — un
|
||||
élément posé à l'intérieur d'un conteneur/répéteur/groupe de champs
|
||||
remplit la largeur disponible et s'ajuste en hauteur à son contenu).
|
||||
Une valeur non nulle impose une taille fixe et n'est plus comprimée par
|
||||
la disposition flex environnante : c'est ce qui permet de redimensionner
|
||||
une image (ou n'importe quel autre élément) posée à l'intérieur d'un
|
||||
conteneur — jusqu'ici impossible.
|
||||
- **Correction — collision en disposition "ligne"** : dans un conteneur
|
||||
réglé sur "Alignés côte à côte (ligne)", chaque élément posé à
|
||||
l'intérieur réclamait par défaut 100% de la largeur du conteneur (comme
|
||||
dans les autres dispositions) — deux éléments côte à côte se
|
||||
retrouvaient donc à se disputer toute la largeur, et la disposition
|
||||
flex les comprimait fortement pour les faire tenir (un des deux, souvent
|
||||
une image, pouvait finir écrasé à quelques pixels de large, voire
|
||||
disparaître visuellement). Par défaut, un élément posé dans une
|
||||
disposition en ligne prend maintenant sa taille naturelle (comme le
|
||||
ferait un élément Elementor/Webflow), ce qui rend aussi immédiatement
|
||||
visibles "Espacement égal entre eux / autour / uniforme" : tant que
|
||||
chaque élément occupait 100% de la largeur, il n'y avait aucun espace
|
||||
restant à répartir entre eux.
|
||||
- **Image — ajustement dans son cadre (object-fit)** : nouveau réglage
|
||||
(Remplir en recadrant / Tout montrer sans déformer / Étirer) qui
|
||||
contrôle comment l'image se comporte quand sa taille réelle ne
|
||||
correspond pas exactement à la taille de son cadre — utile dès qu'une
|
||||
largeur et une hauteur fixes sont données à une image (voir "Taille
|
||||
dans le conteneur" ci-dessus) pour éviter qu'elle soit déformée.
|
||||
"Remplir en recadrant" est désormais la valeur par défaut (avant, une
|
||||
image étirée dans un cadre qui n'avait pas ses proportions était
|
||||
déformée sans recours).
|
||||
- **Nommer un élément** : chaque élément peut recevoir un nom (ex: "Bouton
|
||||
Valider", "Image du héros") dans ses propriétés, pour s'y retrouver
|
||||
dans les listes et dans le choix d'une cible d'action sans avoir à
|
||||
deviner lequel est lequel parmi plusieurs éléments du même type. Sans
|
||||
nom, l'élément garde son libellé de type par défaut (ex: "Image").
|
||||
- **Sélection directe d'un élément imbriqué** : plus besoin de repasser
|
||||
par la liste "Éléments de cet écran" à chaque fois — un clic sur
|
||||
l'écran sélectionne précisément l'élément touché, même s'il est
|
||||
physiquement à l'intérieur d'un conteneur, d'un répéteur ou d'un
|
||||
groupe de champs (le glisser-déposer, lui, continue de déplacer la
|
||||
boîte de plus haut niveau, comme avant).
|
||||
- **Éléments de jeu** (`🧩` depuis le tableau de bord) : un catalogue de
|
||||
conteneurs réutilisables (Personnage, Outil, icône de mail...), chacun
|
||||
bâti exactement comme un écran — on ouvre "✏️ Modifier le contenu" et on
|
||||
y imbrique/stylise des éléments avec l'éditeur d'écran normal (c'est en
|
||||
réalité un écran caché, invisible dans la liste des écrans du jeu et en
|
||||
mode jouable). On peut optionnellement le lier à un objet, pour utiliser
|
||||
`{{nom_du_champ}}` dans son contenu. Une fois construit, il a deux
|
||||
usages :
|
||||
- **Posé directement sur un écran** (bouton "🧩 Élément de jeu" dans
|
||||
"+ Ajouter un élément") : tout son contenu est copié en profondeur en
|
||||
éléments indépendants sur cet écran, modifiables séparément par la
|
||||
suite — comme avant. Limite de cette version : si l'élément est lié à
|
||||
un objet, `{{nom_du_champ}}` n'est alors résolu par aucune ligne
|
||||
précise et reste affiché tel quel ; ce mode convient surtout aux
|
||||
éléments décoratifs (icônes, outils, décor) sans lien à un objet.
|
||||
- **Choisi comme "Modèle de ligne" d'un Répéteur de données** (réglage
|
||||
du Répéteur, à la place du modèle de texte) : pour chaque ligne de
|
||||
l'objet affiché par le Répéteur, son contenu est réaffiché en direct
|
||||
(jamais copié en base) avec les `{{nom_du_champ}}` de cette ligne —
|
||||
c'est ainsi qu'un objet "Mail" avec 5 lignes peut s'afficher comme 5
|
||||
cartes stylisées (icône + sujet + expéditeur...) plutôt que comme du
|
||||
texte brut. Le Répéteur et l'éditeur d'écran n'ont besoin de rien
|
||||
connaître du contenu de l'élément de jeu utilisé : la mise en forme
|
||||
vit entièrement dans le catalogue, réutilisable d'un écran (ou d'un
|
||||
jeu) à l'autre.
|
||||
Supprimer un élément de jeu du catalogue supprime aussi son contenu
|
||||
(l'écran caché) ; c'est bloqué tant qu'au moins un exemplaire est posé
|
||||
directement sur un écran (voir "Suppression protégée" ci-dessous).
|
||||
- **Mode jouable** (`▶️ Jouer`) : joue réellement l'enchaînement des
|
||||
écrans et déclenche les actions au clic, dans le navigateur, à partir
|
||||
des données enregistrées (y compris les Répéteurs, lus en direct) —
|
||||
aucun fichier généré.
|
||||
- Suppression protégée : un écran ciblé par une action, ou un élément de
|
||||
jeu posé sur un écran, ne peuvent pas être supprimés tant que la
|
||||
référence existe.
|
||||
- Limite volontaire de cette version : le Tableau et la Liste déroulante
|
||||
se remplissent encore via un champ de texte (une ligne par entrée), pas
|
||||
en y glissant d'autres éléments un par un — l'imbrication réelle
|
||||
(Conteneur / Répéteur de données / Groupe de champs) est, elle,
|
||||
disponible (voir "Imbrication réelle d'éléments" ci-dessus).
|
||||
- **Corrections** : le bouton "Définir un nouvel objet" du tableau de bord
|
||||
a désormais le même style de carte que les autres raccourcis (Écrans,
|
||||
Éléments de jeu, Jouer) ; une erreur `database is locked` pouvait
|
||||
survenir en supprimant un élément (deux requêtes SQLite concurrentes
|
||||
sur le serveur de développement) — corrigé en activant le mode WAL et
|
||||
un délai d'attente sur les verrous de la base.
|
||||
- **Confusion "Ajouter un élément" vs "Ajouter DANS ce conteneur"** :
|
||||
les deux grilles se ressemblaient à l'identique (mêmes icônes, même
|
||||
mise en page), alors qu'elles ne posent pas l'élément au même endroit
|
||||
— celle de gauche le pose directement SUR L'ÉCRAN, celle de droite (qui
|
||||
n'apparaît que si un conteneur/répéteur/groupe de champs est
|
||||
sélectionné) le pose À L'INTÉRIEUR de cet élément. Cliquer par erreur
|
||||
sur celle de gauche pendant qu'un conteneur est sélectionné posait un
|
||||
nouvel élément par-dessus tout le reste (toujours à la même position
|
||||
par défaut), qui pouvait donner l'impression trompeuse qu'un "élément
|
||||
indésirable" venait d'apparaître et d'en cacher un autre. Corrigé par
|
||||
trois changements : la grille de gauche s'appelle maintenant "Ajouter
|
||||
un élément **sur l'écran**" (sans ambiguïté) ; celle de droite
|
||||
s'appelle "📥 Ajouter **DANS ce conteneur**" avec un habillage vert
|
||||
distinct ; et un avertissement apparaît dans le panneau de gauche
|
||||
quand un conteneur est sélectionné, pour rappeler laquelle des deux
|
||||
grilles utiliser. Un nouvel élément posé directement sur l'écran est
|
||||
aussi désormais légèrement décalé par rapport au précédent (au lieu de
|
||||
toujours atterrir exactement à la même position), pour limiter les
|
||||
recouvrements accidentels même en cas d'usage normal.
|
||||
- **Une couleur de fond apparaissait toute seule en changeant la
|
||||
disposition/l'alignement d'un conteneur, et un élément pouvait
|
||||
"disparaître"** : le panneau de propriétés d'un élément envoie TOUS ses
|
||||
réglages dans un seul formulaire — donc enregistrer un changement de
|
||||
disposition envoyait aussi, par exemple, le champ de couleur de fond,
|
||||
même non modifié. Or un `<input type="color">` affiche toujours une
|
||||
valeur (la couleur par défaut du réglage, purement indicative, quand
|
||||
rien n'a encore été choisi) — cette valeur d'aperçu était donc écrite
|
||||
"en dur" dans le style à chaque enregistrement, quel que soit le champ
|
||||
réellement modifié, ce qui faisait apparaître un fond qui n'avait
|
||||
jamais été demandé (et, en s'accumulant avec d'autres réglages par
|
||||
défaut, pouvait rendre un élément visuellement méconnaissable). Corrigé
|
||||
à la racine : un réglage n'est désormais écrit dans le style/les
|
||||
attributs que s'il a été explicitement modifié — sa valeur par défaut
|
||||
reste un simple aperçu tant qu'on n'y touche pas. Les réglages dont la
|
||||
valeur par défaut a un effet visuel réellement voulu dès la création
|
||||
(ex. "Ajustement dans son cadre" = cover pour une image, afin qu'elle
|
||||
se recadre proprement une fois redimensionnée plutôt que d'être
|
||||
étirée) sont désormais fixés directement à la pose de l'élément, pas
|
||||
au premier enregistrement du panneau — cela ne dépend donc plus de
|
||||
l'ordre dans lequel les réglages sont modifiés.
|
||||
### Ce qui a été délibérément retiré
|
||||
|
||||
### Sur l'ordre de création avec des relations
|
||||
Une première version du moteur exposait aussi un type d'écran "document"
|
||||
— un éditeur générique façon no-code (blocs de logique en nœuds, timeline
|
||||
d'animation, définitions d'objets/champs/relations façon base de
|
||||
données, templates réutilisables). Cette complexité ne correspond à
|
||||
aucun besoin du public cible (formateurs non techniques) et a été
|
||||
supprimée pour recentrer entièrement le produit sur le jeu 2D — voir
|
||||
l'historique Git pour le détail de ce retrait.
|
||||
|
||||
Une relation pointe toujours vers un objet **déjà défini**. Donc si tu veux
|
||||
un objet "Niveau" avec un champ relation vers "Parcours", il faut créer
|
||||
l'objet **"Parcours" en premier** (même sans aucune donnée dedans), puis
|
||||
créer "Niveau" et choisir "Parcours" comme objet lié pour son champ
|
||||
relation. C'est l'inverse de l'exemple donné : c'est l'objet **visé** par
|
||||
la relation qui doit exister avant l'objet qui la porte — pas l'objet qui
|
||||
la porte avant l'objet visé.
|
||||
## Ce qui manque encore pour une mise sur le marché (voir le cadrage)
|
||||
|
||||
### Navigation sans rechargement de page (zéro rechargement)
|
||||
Sur les 6 fonctionnalités listées comme bloquantes (condition d'achat) par
|
||||
le document de cadrage, seul l'**export SCORM** est construit. Restent à
|
||||
faire : support **xAPI**, intégration **LTI 1.3**, **hébergement UE +
|
||||
RGPD** (DPA), accessibilité **RGAA/WCAG 2.2 AA**. L'export HTML5
|
||||
responsive existe déjà via le mode Jouer.
|
||||
|
||||
Toute la navigation dans l'application (pas seulement l'éditeur d'écran)
|
||||
passe maintenant par `static/pjax.js`, une petite couche "PJAX" (dans
|
||||
l'esprit de Turbo/Hotwire) : chaque clic sur un lien interne et chaque
|
||||
soumission de formulaire interne est intercepté, la page suivante est
|
||||
récupérée en arrière-plan (`fetch`), et seuls le `<title>`, l'en-tête (fil
|
||||
d'Ariane) et le contenu principal (`<main>`) sont remplacés — au lieu de
|
||||
laisser le navigateur recharger toute la page. L'URL affichée et le bouton
|
||||
"précédent" du navigateur restent corrects grâce à
|
||||
`history.pushState`/`popstate`.
|
||||
|
||||
Pourquoi PJAX plutôt qu'une réécriture complète en SPA (React ou autre) :
|
||||
le moteur reste 100% rendu côté serveur en Jinja2, ce qui est le bon choix
|
||||
pour un outil interne piloté par une base SQLite par jeu — une SPA aurait
|
||||
demandé de dupliquer toute la logique d'affichage côté client (une API
|
||||
JSON, un routeur, un state management) pour un gain quasi nul ici, alors
|
||||
que PJAX obtient le même résultat perçu ("zéro rechargement", historique
|
||||
correct, formulaires qui marchent) en ne touchant qu'à une seule couche
|
||||
fine, sans toucher aux routes Flask ni aux templates existants.
|
||||
|
||||
Points d'attention si tu ajoutes une page avec un script propre à cette
|
||||
page (dans `{% block content %}` ou via `extra_head`) :
|
||||
|
||||
- Ce script est réinjecté et **réexécuté** à chaque navigation vers cette
|
||||
page (y compris quand on y revient plusieurs fois) : évite `let`/`const`
|
||||
au premier niveau du script (une redéclaration lèverait une erreur) — une
|
||||
déclaration de fonction ou une variable `var` est sans risque.
|
||||
- Pour désactiver PJAX sur un lien ou un formulaire précis (rare), ajoute
|
||||
l'attribut `data-no-pjax`. Les liens `target="_blank"` (ex. "▶️ Jouer")
|
||||
et les liens de téléchargement (`download`) sont déjà ignorés
|
||||
automatiquement.
|
||||
- Les formulaires protégés par un `confirm()` JavaScript (ex. suppression)
|
||||
continuent de fonctionner normalement : si l'utilisateur annule la
|
||||
boîte de dialogue, PJAX n'intercepte rien et rien ne se passe.
|
||||
|
||||
### Éditeur d'écran : tout est automatique (déplacer, redimensionner, régler)
|
||||
|
||||
L'éditeur d'écran (`templates/screen_edit.html`) va plus loin que le PJAX
|
||||
général ci-dessus, parce qu'il s'agit d'un usage bien plus intensif :
|
||||
déplacer/redimensionner un élément à la souris et régler ses propriétés
|
||||
sont des actions qu'on répète en continu pendant qu'on construit un écran,
|
||||
pas des navigations occasionnelles. Deux comportements dédiés, indépendants
|
||||
de `pjax.js` :
|
||||
|
||||
- **Glisser-déposer et redimensionnement** ne provoquent plus aucune
|
||||
navigation. Tant que l'élément déplacé/redimensionné reste celui déjà
|
||||
sélectionné, rien n'est rechargé : sa nouvelle position/taille est
|
||||
envoyée en arrière-plan (`fetch`) et les champs "X/Y/Largeur/Hauteur" du
|
||||
panneau de droite sont mis à jour directement en JavaScript. Cliquer
|
||||
(sans glisser) sur un AUTRE élément pour le sélectionner ne recharge pas
|
||||
la page non plus : seul le bloc central de l'éditeur (`#builder3` — la
|
||||
liste d'éléments, le canevas et le panneau de propriétés) est regénéré
|
||||
via une requête en arrière-plan et réinjecté, sans toucher au reste de la
|
||||
page (l'éditeur de logique en bas, par exemple, garde son état).
|
||||
- **Le panneau de propriétés s'enregistre entièrement tout seul** — il n'y
|
||||
a plus de bouton "Enregistrer". Chaque réglage se sauvegarde dès qu'on le
|
||||
change : immédiatement pour une case à cocher, une couleur ou une liste
|
||||
déroulante ; après une courte pause (500 ms) pour un champ texte ou un
|
||||
slider qu'on est en train de glisser, pour éviter d'envoyer une requête à
|
||||
chaque caractère tapé. Un indicateur ("Enregistrement..." / "Enregistré
|
||||
automatiquement ✓") remplace l'ancien bouton. Après chaque sauvegarde,
|
||||
seul le canevas (`#canvas`) est rafraîchi pour refléter le changement
|
||||
visuellement — le formulaire de propriétés lui-même n'est jamais
|
||||
retouché, pour ne jamais faire perdre le focus ou la position du curseur
|
||||
pendant qu'on tape.
|
||||
|
||||
Point d'attention si tu ajoutes un nouveau contrôle ou une nouvelle zone
|
||||
dans ce panneau : toute logique branchée avec `addEventListener` (pas un
|
||||
attribut `onclick`/`onchange` inline) doit être (re)branchée dans
|
||||
`initBuilderPanel()` — cette fonction est rappelée après chaque changement
|
||||
de sélection, puisque `#builder3` est entièrement regénéré à ce moment-là.
|
||||
|
||||
### Cliquer une ligne de Répéteur (déclencheur + action "Ouvrir la ligne cliquée")
|
||||
|
||||
Un Répéteur affiche une ligne par entrée d'un objet de données (ex : la
|
||||
liste des mails d'une boîte de réception), mais toutes ses lignes
|
||||
partagent le même modèle d'éléments — il n'existait donc aucun moyen de
|
||||
dire "au clic sur CETTE ligne précise, ouvre CE mail précis" : un
|
||||
déclencheur ne pouvait viser qu'un élément fixe posé une fois pour toutes
|
||||
sur l'écran.
|
||||
|
||||
Ça fonctionne maintenant ainsi :
|
||||
|
||||
- Chaque ligne rendue par un Répéteur porte un attribut `data-row-id` (le
|
||||
vrai id de la ligne de données, voir `screens/rendering/render_repeater.py`)
|
||||
— à ne pas confondre avec `data-element-id`, qui reste l'id du MODÈLE de
|
||||
ligne et se répète à l'identique sur chaque ligne.
|
||||
- Dans l'éditeur de logique, choisir le Répéteur lui-même comme "Élément"
|
||||
d'un nœud Déclencheur ("Au clic") fait réagir n'importe laquelle de ses
|
||||
lignes au clic (le clic remonte naturellement jusqu'au conteneur du
|
||||
Répéteur).
|
||||
- La nouvelle action **"Ouvrir la ligne de Répéteur cliquée"** retient
|
||||
quelle ligne a réellement été cliquée (`window.lastClickedRowId` /
|
||||
`window.lastClickedDefinitionId`, capturés au clic dans `bindClicks()`
|
||||
de `templates/play.html`), affiche l'écran de détail choisi, puis
|
||||
résout tous les `{{champ}}` restés tels quels sur cet écran
|
||||
(`applyOpenRowBindings()`) avec les valeurs de CETTE ligne — alors que
|
||||
jusqu'ici `{{champ}}` ne se résolvait qu'à l'intérieur d'un Répéteur
|
||||
(limitation encore documentée dans les tests pour un élément posé
|
||||
directement depuis le catalogue).
|
||||
- Limite connue : si le déclencheur est posé sur un élément à l'INTÉRIEUR
|
||||
du modèle de ligne (ex: juste le titre) plutôt que sur le Répéteur
|
||||
lui-même, `data-definition-id` n'est pas disponible sur cet élément et
|
||||
l'action ne saura pas résoudre la donnée — pose toujours le déclencheur
|
||||
sur le Répéteur.
|
||||
|
||||
### Un seul écran qui s'adapte à la partie (filtre de Répéteur + déclencheur "affichage")
|
||||
|
||||
Certains jeux (ex: une boîte mail avec plusieurs niveaux) n'ont besoin que
|
||||
d'un SEUL écran de jeu, dont le contenu doit changer selon l'état de la
|
||||
partie (niveau atteint, outils débloqués...) plutôt que d'un écran par
|
||||
niveau à maintenir en synchronisation. Deux briques rendent ça possible :
|
||||
|
||||
- **Filtre sur un Répéteur** (nouveaux réglages "Filtre" dans ses
|
||||
propriétés) : ne garde que les lignes dont un champ correspond à une
|
||||
valeur — fixe (ex. `3`) OU une référence `{{NomDeLObjet.nom_du_champ}}`
|
||||
qui va lire la valeur ACTUELLE de ce champ sur la ligne la plus récente
|
||||
de cet autre objet (voir `screens/rendering/filter_repeater_rows.py`).
|
||||
Convention : un objet utilisé comme "état de partie" (ex. un objet
|
||||
"Partie" avec un champ "niveau_courant") ne garde qu'UNE seule ligne,
|
||||
mise à jour en place par des actions "Modifier une donnée" plutôt que
|
||||
d'en créer une nouvelle à chaque fois — la référence prend toujours la
|
||||
ligne la plus récente. Le filtre est réévalué à chaque régénération du
|
||||
HTML du jeu (chargement de `/play`, et rafraîchissement après toute
|
||||
action "Modifier une donnée"), donc automatiquement à jour.
|
||||
- **Déclencheur "À l'affichage de l'écran"** (nouvel événement, en plus de
|
||||
"Au clic" et "À la soumission") : contrairement aux autres déclencheurs,
|
||||
il ne cible pas un élément précis mais l'ÉCRAN ENTIER, et s'exécute
|
||||
automatiquement — au premier affichage, à chaque retour sur cet écran, ET
|
||||
après toute donnée modifiée pendant qu'on y est déjà (voir
|
||||
`runScreenShowTriggers()` dans `templates/play.html`, appelée depuis
|
||||
`showScreen()` et `refreshRuntimeData()`). Combiné à un nœud Condition et
|
||||
une action "Modifier un élément → Visibilité", ça permet de cacher/montrer
|
||||
un élément selon l'état de la partie SANS qu'un clic soit nécessaire pour
|
||||
le réévaluer — la limite qui empêchait un outil de réapparaître "débloqué"
|
||||
simplement en revenant sur l'écran. À éviter avec le mode "va-et-vient"
|
||||
d'une action liée : il serait réévalué à chaque affichage et basculerait
|
||||
de façon imprévisible plutôt que de rester stable.
|
||||
|
||||
Point d'attention : le filtre de Répéteur ne fait pas de vraie jointure —
|
||||
la référence `{{Objet.champ}}` prend la ligne la plus récente de l'objet
|
||||
visé, pas "la ligne liée à la ligne courante d'un autre Répéteur" ; pour un
|
||||
état de partie à une seule ligne (le cas d'usage visé), ça suffit.
|
||||
|
||||
## Jauge liée à une donnée, bornage automatique, onglets, conditions combinées
|
||||
|
||||
Quatre briques qui se combinent bien avec le déclencheur "À l'affichage de
|
||||
l'écran" ci-dessus, pour construire un écran qui réagit à l'état de la
|
||||
partie sans code :
|
||||
|
||||
- **Widget "Jauge (liée à une donnée)"** (nouveau widget, icône 📊) :
|
||||
affiche une barre dont la largeur ET la couleur suivent en direct un
|
||||
champ numérique d'un objet — pas besoin d'ajouter la moindre action, la
|
||||
jauge se relit automatiquement à chaque rafraîchissement des données
|
||||
(chargement de `/play`, après toute action "Modifier une donnée"). Ses
|
||||
réglages : Objet, Champ, Min/Max (bornes d'affichage — la barre est visuellement
|
||||
clampée à 0%/100% même si la vraie valeur dépasse), Couleur basse/haute
|
||||
(dégradé interpolé linéairement selon la position dans la plage), et une
|
||||
case "Afficher la valeur" pour incruster le nombre au centre de la barre.
|
||||
Lit toujours la ligne la PLUS RÉCENTE de l'objet visé (même convention
|
||||
"état de partie à une seule ligne" que le filtre de Répéteur). Voir
|
||||
`screens/rendering/render_jauge.py`.
|
||||
- **Bornage automatique des champs numériques** : un champ "Nombre entier"
|
||||
ou "Nombre décimal" peut désormais avoir un Min et/ou un Max réglés à la
|
||||
création de l'objet (ou ajoutés après coup en édition). Toute action
|
||||
"Modifier une donnée" (Augmenter de, Diminuer de, Définir à...) qui
|
||||
ferait sortir la valeur de cette plage est automatiquement RAMENÉE à la
|
||||
borne dépassée, plutôt que de continuer à s'accumuler sans limite — plus
|
||||
besoin de poser une Condition séparée juste pour empêcher une jauge de
|
||||
réputation de dépasser 100 ou de passer sous 0. Voir
|
||||
`_clamp_to_field_bounds()` dans `screens/data_actions/apply_data_action.py`.
|
||||
- **Panneau à onglets / visibilité mutuellement exclusive** : nouveau type
|
||||
d'action "Afficher cet élément, masquer tous ses frères (onglets /
|
||||
exclusif)" (`activer_onglet`). Au lieu de poser une action "Modifier
|
||||
élément → Visibilité" par bouton à masquer (N-1 actions pour N onglets),
|
||||
UNE SEULE action suffit : au clic, elle montre l'élément ciblé et cache
|
||||
automatiquement tous les autres éléments qui partagent le même parent
|
||||
(même conteneur) dans l'écran — exactement le comportement d'un jeu
|
||||
d'onglets ou d'un menu à options exclusives. Voir la fonction
|
||||
`runActionNode()` (branche `'activer_onglet'`) dans `templates/play.html`.
|
||||
- **Conditions combinées (ET / OU)** : un nœud Condition peut désormais
|
||||
tester PLUSIEURS champs à la fois dans un seul nœud, au lieu d'enchaîner
|
||||
plusieurs nœuds Condition pour un ET, ou de dupliquer une action derrière
|
||||
plusieurs nœuds Condition pour un OU. Dans le formulaire d'un nœud
|
||||
Condition, le bouton "+ Ajouter une condition" ajoute une clause
|
||||
supplémentaire (Objet / Ligne / Champ / Condition / Valeur, comme la
|
||||
clause principale) et fait apparaître un sélecteur "Combiner les
|
||||
conditions avec : ET / OU". Exemple : "réputation < 20 OU niveau ≥ 3".
|
||||
Compatibilité : un nœud créé AVANT cette fonctionnalité (ou un nœud à une
|
||||
seule clause) n'a pas de `cond_clauses` en base et garde exactement son
|
||||
ancien comportement (une seule comparaison) — voir `cond_clauses` /
|
||||
`cond_combinator` dans `screens/flow/ensure_flow_schema.py` et
|
||||
`evaluateConditionNode()` / `evaluateConditionClause()` dans
|
||||
`templates/play.html`.
|
||||
|
||||
## Survol, séquences temporisées, surbrillance, overlay, verrouillage
|
||||
|
||||
Cinq briques de confort, chacune contournable en théorie mais coûteuse en
|
||||
câblage manuel sans elles :
|
||||
|
||||
- **Interactions au survol** : un nouveau réglage "Texte affiché au survol
|
||||
de la souris", disponible sur TOUS les widgets (via UNIVERSAL_CONTROLS —
|
||||
voir `screens/widgets/control_groups/hover_controls.py`), échange le
|
||||
texte affiché contre ce texte alternatif tant que la souris survole
|
||||
l'élément, puis le restaure au départ de la souris — exactement le cas
|
||||
d'usage visé ("survoler un nom révèle l'adresse réelle, survoler un lien
|
||||
révèle l'URL réelle"). Laissé vide = aucun changement, donc aucune
|
||||
régression sur les éléments déjà créés. Ignoré volontairement sur les
|
||||
éléments qui ont des enfants (conteneurs), pour ne jamais écraser une
|
||||
mise en page imbriquée. Voir l'attribut `data-hover-text` posé par
|
||||
`render_element_html.py` et `bindHoverTexts()` dans `templates/play.html`.
|
||||
- **Séquences temporisées** : nouveau type d'action "Attendre quelques
|
||||
secondes avant de continuer" (`attendre`), qui suspend la SUITE du fil de
|
||||
logique (les nœuds reliés après lui) pendant N secondes avant de
|
||||
continuer — combiné au déclencheur "À l'affichage de l'écran" (voir plus
|
||||
haut), ça permet un mail ou un événement qui "arrive" tout seul quelques
|
||||
secondes après l'ouverture de l'écran, sans action du joueur. Le joueur
|
||||
continue d'interagir normalement avec le reste de l'écran pendant
|
||||
l'attente. Voir la branche `'attendre'` de `runActionNode()` dans
|
||||
`templates/play.html`.
|
||||
- **Surbrillance générique dynamique** : nouvelle valeur "Surbrillance"
|
||||
pour l'action "Modifier un élément", applicable à N'IMPORTE QUEL élément
|
||||
(pas seulement les boutons) — pose un liseré doré clignotant (`.forgeHighlight`,
|
||||
animation CSS) pour attirer l'attention du joueur vers ce qu'il doit
|
||||
toucher ensuite (ex. un mentor qui "montre" un bouton), sans avoir à
|
||||
poser une bordure de couleur togglée à la main. Activer / Désactiver /
|
||||
Basculer, comme la Visibilité.
|
||||
- **Overlay / modale réutilisable** : nouveau widget "Superposition / boîte
|
||||
de dialogue" (icône 🪟), prêt à l'emploi — contrairement à tous les autres
|
||||
widgets, sa position ne dépend PAS de l'endroit où il est glissé sur le
|
||||
canevas (`position:fixed; inset:0`, codé en dur) : il couvre TOUJOURS tout
|
||||
l'écran, avec un voile semi-transparent et une boîte centrée qui contient
|
||||
les éléments posés à l'intérieur (comme un conteneur normal). Démarre
|
||||
MASQUÉ par défaut à sa création (sinon il couvrirait l'écran dès qu'on le
|
||||
pose) — l'ouverture et la fermeture réutilisent l'action existante
|
||||
"Modifier un élément → Visibilité" (rendre visible / masquer), fermeture
|
||||
manuelle uniquement, sans mécanisme de clic-en-dehors-pour-fermer. Voir
|
||||
`screens/rendering/render_overlay.py`.
|
||||
- **Verrouillage d'un élément après décision** : nouvelle valeur
|
||||
"Désactivé" pour l'action "Modifier un élément", DISTINCTE de
|
||||
"Invisible" — l'élément reste visible mais devient grisé et inerte
|
||||
(`.forgeDisabled` : opacité réduite + `pointer-events:none`, qui bloque
|
||||
aussi le déclencheur "Au clic" éventuellement posé dessus). Utile pour un
|
||||
bouton "Répondre" qui reste affiché mais ne doit plus pouvoir être
|
||||
recliqué une fois le mail traité. Activer / Désactiver / Basculer.
|
||||
|
||||
## Structure
|
||||
|
||||
Le code est organisé en un fichier par fonction et un dossier par
|
||||
responsabilité — pensé pour rester modifiable, testable et évolutif à
|
||||
mesure que le moteur grandit, plutôt que quelques gros fichiers monolithiques.
|
||||
`app.py` reste le point d'entrée (`python3 app.py` ne change pas), mais il
|
||||
n'est plus qu'un mince assemblage : toute la logique vit dans les dossiers
|
||||
ci-dessous.
|
||||
## Structure du code
|
||||
|
||||
```
|
||||
app.py point d'entrée : assemble core/ + routes/, lance le serveur
|
||||
app.py point d'entrée : assemble core/ + routes/, lance le serveur
|
||||
|
||||
core/ pièces transverses de l'application Flask
|
||||
flask_app.py crée l'instance Flask partagée (app)
|
||||
jinja_filters.py enregistre les filtres Jinja (colname, elstyle, elabel)
|
||||
core/ pièces transverses (instance Flask, filtres Jinja, CSRF, auth)
|
||||
filters/ filtres Jinja restants (colname)
|
||||
|
||||
filters/ un fichier par filtre Jinja
|
||||
colname_filter.py
|
||||
element_style_filter.py
|
||||
|
||||
routes/ un fichier par route HTTP, regroupées par domaine
|
||||
games/ créer/renommer/supprimer un jeu, tableau de bord
|
||||
objects/ définitions d'objet, champs, données (CRUD)
|
||||
screens/ écrans du jeu (liste, création, édition...)
|
||||
elements/ éléments posés sur un écran
|
||||
element_types/ catalogue d'éléments de jeu réutilisables
|
||||
flow/ éditeur de logique à nœuds (déclencheur/condition/action)
|
||||
legacy_actions/ ancien système d'actions par élément (conservé, non utilisé par l'interface actuelle)
|
||||
routes/ une route HTTP = un fichier, regroupées par domaine
|
||||
auth/ inscription, connexion, 2FA, mot de passe
|
||||
onboarding/ parcours de création guidé ("Quel jeu veux-tu créer ?")
|
||||
games/ créer/renommer/supprimer un jeu, tableau de bord ("Mes écrans")
|
||||
screens/ écrans du jeu (liste, création, renommage, taille de scène...)
|
||||
scenes/ objets de scène (personnage/décor/fond/widgets), rôle, collision, nom...
|
||||
quests/ quêtes + dialogues
|
||||
custom_events/ événements personnalisés (déclenchés/écoutés depuis une scène)
|
||||
flow/, flow_blocks/ moteur de logique à nœuds (partagé, utilisé par les règles de collision)
|
||||
animations/ clips d'animation (Animate.css + images-clés)
|
||||
global_vars/ variables globales du jeu
|
||||
publish/ export SCORM
|
||||
play/, public_play/ mode jouable
|
||||
uploads/ envoi de fichiers (images/vidéos)
|
||||
play/ mode jouable
|
||||
|
||||
db/ couche données "objets" — un fichier par fonction
|
||||
connection.py, slugify.py, table_name_for.py, game_dir.py, db_path.py, constants.py
|
||||
games/ cycle de vie d'un jeu
|
||||
definitions/ définitions d'objet et leurs champs
|
||||
rows/ lignes de données (CRUD)
|
||||
db/ couche données bas niveau (SQLite, un fichier .db par jeu)
|
||||
games/ cycle de vie d'un jeu, catalogue d'onboarding
|
||||
definitions/, rows/ objets de données typés + leurs lignes (utilisés par le flow)
|
||||
quests/ quêtes et leur dialogue
|
||||
custom_events/, global_vars/, scoring/
|
||||
|
||||
screens/ couche données + rendu "écrans de jeu" — un fichier par fonction
|
||||
widgets/ catalogue de widgets, réglages ("controls"), groupes de réglages
|
||||
rendering/ construction du HTML réel d'un élément (récursif)
|
||||
screens_repo/ CRUD des écrans
|
||||
elements/ CRUD des éléments posés sur un écran
|
||||
element_types/ catalogue d'éléments de jeu réutilisables (conteneurs imbriqués)
|
||||
flow/ moteur de logique à nœuds
|
||||
data_actions/ exécution d'une action "modifier une donnée"
|
||||
legacy_actions/ ancien système d'actions (conservé)
|
||||
labels/ constantes d'affichage (libellés)
|
||||
payload/ assemble les données du mode jouable
|
||||
screens/ couche rendu — un fichier par fonction
|
||||
scenes/ objets de scène : ajout, rendu, rôle, collision, commandes, dialogue...
|
||||
rendering/ personnage (data/animations/rôle), collision, dialogue, quêtes
|
||||
flow/ moteur de logique à nœuds (partagé)
|
||||
data_actions/ exécution d'une action de flow (modifier une donnée, un score...)
|
||||
animations/, custom_events/, labels/, payload/, screens_repo/
|
||||
|
||||
templates/ pages HTML (Jinja2) — inchangé
|
||||
static/ CSS + JS — inchangé
|
||||
projects/ un sous-dossier par jeu créé (généré à l'usage) — inchangé
|
||||
templates/ pages HTML (Jinja2)
|
||||
static/ CSS + JS (static/js/scenes/ = éditeur de scène, static/js/play/ = moteur de jeu)
|
||||
projects/ un sous-dossier par jeu créé (généré à l'usage, non versionné)
|
||||
|
||||
tests/ suite de tests automatisés (pytest)
|
||||
conftest.py fixtures partagées (client Flask de test, jeu de test jetable)
|
||||
test_db_layer.py couche données, sans Flask
|
||||
test_screens_and_elements.py écrans/éléments/éléments de jeu/répéteur, via le client de test
|
||||
test_flow.py éditeur de logique à nœuds
|
||||
tests/ suite pytest
|
||||
```
|
||||
|
||||
Chaque `import db` / `import screens` continue de fonctionner exactement
|
||||
comme avant (`db.connect(...)`, `screens.render_element_html(...)`, etc.) :
|
||||
`db/__init__.py` et `screens/__init__.py` réexportent l'intégralité de
|
||||
l'API publique historique, pour que ce découpage interne reste invisible du
|
||||
reste du moteur — aucune route, aucun template n'a eu besoin de changer.
|
||||
|
||||
### Lancer les tests
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
## Corrections (retours d'usage)
|
||||
## Notes techniques
|
||||
|
||||
- **ID d'élément en champ non modifiable** : dans le panneau de propriétés,
|
||||
l'ID de l'élément (`elt-…`) est maintenant affiché dans un champ texte
|
||||
`readonly`, plutôt qu'en simple texte.
|
||||
- **Bouton "+10 points" instable en mode Jouer (corrigé)** : un bouton relié
|
||||
à une action "Modifier une donnée" (ex: incrémenter un score) pouvait, après
|
||||
plusieurs clics, appliquer l'effet plusieurs fois d'un coup (+100, -20, ou
|
||||
retomber à 0 au lieu de ±10). Cause : chaque donnée modifiée déclenche un
|
||||
rafraîchissement qui ré-attache les gestionnaires de clic sur toute la
|
||||
scène ; un bouton ordinaire (contrairement à un Répéteur ou une Jauge) garde
|
||||
le même nœud HTML d'un rafraîchissement à l'autre, donc ses gestionnaires de
|
||||
clic s'empilaient au lieu d'être remplacés. Un clic fini par déclencher
|
||||
l'action N fois pour N rafraîchissements passés. Corrigé en marquant chaque
|
||||
élément déjà relié, pour ne l'attacher qu'une seule fois.
|
||||
- **Textes d'aide allégés** : les explications détaillées entre parenthèses
|
||||
affichées un peu partout dans l'éditeur ont été retirées ou raccourcies —
|
||||
sur tous les écrans, pas seulement l'éditeur de scène (nouvelle passe
|
||||
complète sur tous les labels, options de menus déroulants et placeholders).
|
||||
- **Espacement (gap) inefficace en disposition par défaut, corrigé** : le
|
||||
réglage "Espace entre les éléments" n'avait aucun effet tant que la
|
||||
"Disposition interne" restait sur "Empilement libre" — le CSS `gap` ne
|
||||
fonctionne que sur un conteneur flex/grid, or "Empilement libre" ne posait
|
||||
aucun `display:flex`. Un conteneur/répéteur/groupe de champs applique
|
||||
maintenant flex + colonne par défaut dès qu'aucune disposition n'a été
|
||||
choisie explicitement (visuellement identique à l'empilement d'avant),
|
||||
donc l'espacement fonctionne dès la création, sans avoir à toucher la
|
||||
disposition au préalable.
|
||||
- **Marges et bordures réglables par côté** : en plus des réglages globaux
|
||||
existants ("Marge intérieure", "Marge extérieure", "Épaisseur de la
|
||||
bordure"), chaque côté (haut/droite/bas/gauche) peut désormais être réglé
|
||||
individuellement — utile par exemple pour une seule bordure basse, ou un
|
||||
padding asymétrique. Les réglages par côté, une fois utilisés, priment sur
|
||||
le réglage global correspondant.
|
||||
- **Apparence de l'éditeur après suppression d'un élément** : supprimer un
|
||||
élément qui a un parent (posé à l'intérieur d'un conteneur/répéteur/groupe
|
||||
de champs) resélectionne maintenant ce parent, au lieu de laisser le
|
||||
panneau de propriétés vide.
|
||||
|
||||
- **Timeline d'animation (Animate.css + animations personnalisées)** : dans
|
||||
l'éditeur d'écran, un nouveau panneau rétractable "🎬 Timeline d'animation"
|
||||
est positionné sous "🔀 Logique de la scène". Il permet de poser des clips
|
||||
d'animation sur n'importe quel élément de l'écran, sur un axe temporel en
|
||||
secondes (depuis l'affichage de l'écran) :
|
||||
- **Animations Animate.css** : choix parmi le catalogue complet de la
|
||||
bibliothèque (apparitions, rebonds, glissements, zooms, rotations,
|
||||
retournements, etc.), avec durée, délai, easing et nombre de répétitions
|
||||
réglables.
|
||||
- **Animations personnalisées** : un éditeur d'images-clés (keyframes) —
|
||||
chaque image-clé a un pourcentage (0-100) et une liste de propriétés CSS
|
||||
(ex. `transform: scale(1.2); opacity: 0.5;`), converties en `@keyframes`
|
||||
CSS au moment de la lecture.
|
||||
- Chaque clip se pose et se règle à la souris directement sur la
|
||||
timeline : glisser un clip change son instant de départ, glisser son
|
||||
bord droit change sa durée ; cliquer dessus (sans glisser) ouvre son
|
||||
formulaire d'édition détaillé, avec un bouton "▶ Aperçu" pour voir
|
||||
l'animation directement sur le canevas d'édition.
|
||||
- En mode Jouer, chaque clip se déclenche automatiquement à son instant de
|
||||
départ lors de l'affichage de l'écran qui le contient.
|
||||
- **Échelle/animation d'un élément posé sur l'écran, corrigée** : l'échelle
|
||||
("Disposition" → curseur "Échelle") et les animations de la timeline
|
||||
restaient visuellement "coincées" à l'intérieur de la boîte d'origine de
|
||||
l'élément, comme si l'effet s'appliquait à un contenu emprisonné dans un
|
||||
cadre immobile — c'était bien le cas : ces réglages vivaient sur la balise
|
||||
intérieure, alors que le cadre qui porte réellement la position/taille de
|
||||
l'élément sur l'écran (et son contour de sélection dans l'éditeur) restait
|
||||
fixe autour. L'échelle et les animations visent maintenant ce cadre
|
||||
directement, comme le reste de l'élément — plus de recadrage parasite. Un
|
||||
élément posé À L'INTÉRIEUR d'un conteneur/répéteur/groupe de champs n'a
|
||||
jamais eu ce souci (pas de cadre séparé pour lui).
|
||||
- **Format d'aperçu (Portrait/Paysage/Carré) mémorisé par écran** : ce choix
|
||||
ne vivait qu'en mémoire dans le navigateur — poser ou supprimer un
|
||||
élément, ou sélectionner un autre élément, redemande ce panneau au
|
||||
serveur, qui repartait alors systématiquement sur Portrait. Résultat :
|
||||
l'écran de travail semblait "s'agrandir ou rétrécir" au fil des actions
|
||||
quand on travaillait en Paysage ou en Carré (retour brutal en Portrait à
|
||||
chaque fois). Le format choisi est maintenant enregistré par écran et
|
||||
restauré à chaque fois que ce panneau est réaffiché.
|
||||
- **Zone de jeu à proportion fixe en mode Jouer, contre la déformation en
|
||||
%** : les positions et tailles des éléments sont en pourcentage de
|
||||
l'écran, ce qui les déformait dès que la fenêtre du joueur n'avait pas
|
||||
exactement la même proportion que celle choisie à la conception (ex. un
|
||||
écran conçu en Portrait, joué dans une fenêtre large, étirait/écrasait
|
||||
tout). La zone de jeu garde maintenant toujours la proportion de l'écran
|
||||
affiché (Portrait/Paysage/Carré, par écran), avec des bandes noires
|
||||
(letterboxing) si besoin — comme un lecteur vidéo — pour rester fidèle à
|
||||
ce qui a été conçu, quel que soit l'écran du joueur.
|
||||
|
||||
## Prochaines fonctionnalités
|
||||
|
||||
À ajouter au fur et à mesure, comme convenu.
|
||||
- **Navigation sans rechargement** : la plupart des actions de l'éditeur
|
||||
de scène (ajout/suppression d'objet, sélection, changement de rôle/nom,
|
||||
règles de collision) passent par des appels `fetch()` dédiés qui ne
|
||||
touchent que le fragment de page concerné. La navigation entre pages
|
||||
différentes (ex. "Mes écrans" ↔ une scène) passe par `static/pjax.js`
|
||||
(interception des clics/formulaires, remplacement de `<main>` +
|
||||
`#pageChrome`) plutôt qu'un rechargement complet — toute règle CSS dont
|
||||
dépend une page doit donc vivre dans `static/style.css` (chargé
|
||||
partout), jamais dans un `<style>` inline propre à cette seule page,
|
||||
qui ne serait pas repris lors d'une navigation pjax vers elle.
|
||||
- **Serveur de développement en mode `threaded=True`** (`app.py`) : une
|
||||
scène charge plusieurs images en parallèle (personnages, fond) — sans
|
||||
ce réglage, le serveur Werkzeug mono-thread les sert une par une.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
"""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})
|
||||
|
||||
response = db.assert_not_none(response, "_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)"
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
+768
@@ -0,0 +1,768 @@
|
||||
"""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": <qui parle>, "text": <texte>}, '
|
||||
'SOIT une QUESTION DE QUIZ {"type":"question", "text", "choices": '
|
||||
'[2 à 4 réponses], "correct_index": <index de la bonne réponse>, '
|
||||
f'"reward_type": {list(QUESTION_REWARD_TYPES)!r}, "reward_amount": <entier>}}. '
|
||||
"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)
|
||||
obj = db.assert_not_none(obj, "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)
|
||||
@@ -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)
|
||||
|
||||
+80
-32
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .connection import connect
|
||||
|
||||
|
||||
def ensure_users_schema():
|
||||
def ensure_users_schema() -> None:
|
||||
conn = connect()
|
||||
conn.executescript(
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<HH", content[6:10])
|
||||
return width, height
|
||||
return None
|
||||
if content[:2] == b"BM":
|
||||
if len(content) >= 26:
|
||||
width, height = struct.unpack("<ii", content[18:26])
|
||||
return abs(width), abs(height)
|
||||
return None
|
||||
if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
|
||||
return _webp_dimensions(content)
|
||||
if content[:2] == b"\xff\xd8":
|
||||
return _jpeg_dimensions(content)
|
||||
return None
|
||||
|
||||
|
||||
def _webp_dimensions(content: bytes) -> 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("<HH", content[26:30])
|
||||
return width & 0x3FFF, height & 0x3FFF
|
||||
if chunk == b"VP8L" and len(content) >= 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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 = ?",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+8
-6
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+3
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<svg"):]
|
||||
return svg[svg.index("<svg") :]
|
||||
|
||||
@@ -9,7 +9,7 @@ class EmailUpdateError(Exception):
|
||||
valide, unicité), voir create_user.py."""
|
||||
|
||||
|
||||
def update_email(user_id, new_email):
|
||||
def update_email(user_id: int, new_email: str | None) -> str:
|
||||
new_email = (new_email or "").strip().lower()
|
||||
if not is_valid_email(new_email):
|
||||
raise EmailUpdateError("Adresse email invalide.")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ""))
|
||||
|
||||
+2
-2
@@ -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))
|
||||
|
||||
+3
-2
@@ -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())
|
||||
|
||||
+60
-19
@@ -1,18 +1,22 @@
|
||||
"""Garde d'accès globale — connexion obligatoire pour tout le moteur, et
|
||||
isolation par utilisateur d'un SEUL projet (sauf le rôle "admin",
|
||||
illimité comme avant l'authentification). Un seul before_request plutôt
|
||||
qu'un décorateur à poser sur chacune des ~80 routes existantes : moins de
|
||||
risque d'en oublier une, et aucun fichier de routes existant n'a besoin
|
||||
d'être modifié.
|
||||
isolation par PROPRIÉTAIRE de projet, pour TOUS les rôles (y compris
|
||||
"admin" — voir plus bas, un admin garde d'autres privilèges mais plus
|
||||
aucun accès aux projets des autres comptes, faille corrigée). Un seul
|
||||
before_request plutôt qu'un décorateur à poser sur chacune des ~80 routes
|
||||
existantes : moins de risque d'en oublier une, et aucun fichier de routes
|
||||
existant n'a besoin d'être modifié.
|
||||
|
||||
Cet import doit avoir lieu APRÈS `import routes` (voir app.py/conftest.py)
|
||||
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
|
||||
|
||||
from .flask_app import app
|
||||
|
||||
@@ -20,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
|
||||
@@ -30,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
|
||||
@@ -53,11 +68,40 @@ def _require_login_and_enforce_project_isolation():
|
||||
return redirect(url_for("login"))
|
||||
g.current_user = user
|
||||
|
||||
# Un compte "user" n'a accès qu'à SON SEUL projet (project_slug) — un
|
||||
# "admin" reste illimité, exactement comme avant l'authentification.
|
||||
# Les routes de gestion multi-jeux (page d'accueil, "+ Nouveau jeu")
|
||||
# n'ont pas leur place pour un compte à projet unique : redirigées
|
||||
# directement vers son propre tableau de bord plutôt qu'un 403 sec.
|
||||
# Propriété d'un projet = le segment "propriétaire" de son slug (voir
|
||||
# db/games/project_slug.py — projects/<owner_folder>/<projet>/,
|
||||
# owner_folder est l'id du compte qui l'a créé, voir
|
||||
# db/games/create_game.py) comparé à l'id du compte CONNECTÉ — pour
|
||||
# TOUS les rôles, admin compris. Corrige une faille : auparavant ce
|
||||
# contrôle (comme tout le reste de cette fonction) était sauté pour
|
||||
# "admin", qui pouvait donc ouvrir/modifier/supprimer le projet de
|
||||
# N'IMPORTE QUEL autre compte en connaissant son slug.
|
||||
#
|
||||
# Un slug "à plat" (un seul segment, `split_slug` renvoie alors
|
||||
# `project_part=None`) n'a jamais été rattaché à un compte précis —
|
||||
# avant l'introduction de project_slug, c'était déjà la norme
|
||||
# (voir scripts/migrate_flat_project_slugs.py), et `db.create_game()`
|
||||
# sans `owner_folder` produit encore ce format aujourd'hui (utilisé
|
||||
# par une bonne partie de la suite de tests comme jeu jetable, sans
|
||||
# compte associé). Réservé au rôle "admin" (repli sur le comportement
|
||||
# "illimité" historique pour ce cas précis) — jamais un "user", qui
|
||||
# n'a par construction aucun projet à plat légitime.
|
||||
slug = request.view_args.get("slug") if request.view_args else None
|
||||
if slug is not None:
|
||||
owner_folder, project_part = split_slug(slug)
|
||||
if project_part is None:
|
||||
if user["role"] != "admin":
|
||||
abort(403)
|
||||
elif owner_folder != str(user["id"]):
|
||||
abort(403)
|
||||
|
||||
# Un compte "user" n'a accès qu'à SON SEUL projet (project_slug,
|
||||
# simple raccourci de confort ici — la sécurité elle-même est déjà
|
||||
# assurée ci-dessus) — un "admin" peut en avoir plusieurs, exactement
|
||||
# comme avant l'authentification. Les routes de gestion multi-jeux
|
||||
# (page d'accueil, "+ Nouveau jeu") n'ont pas leur place pour un
|
||||
# compte à projet unique : redirigées directement vers son propre
|
||||
# tableau de bord plutôt qu'un 403 sec.
|
||||
if user["role"] != "admin":
|
||||
if endpoint in ("index", "games_new") and user.get("project_slug"):
|
||||
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
||||
@@ -68,9 +112,6 @@ def _require_login_and_enforce_project_isolation():
|
||||
# une connexion, absentes de _PUBLIC_ENDPOINTS).
|
||||
if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT:
|
||||
return redirect(url_for("onboarding_new"))
|
||||
slug = request.view_args.get("slug") if request.view_args else None
|
||||
if slug is not None and slug != user.get("project_slug"):
|
||||
abort(403)
|
||||
# Type d'onboarding "restreint" (quiz/embranchement/rpg, voir
|
||||
# db/games/game_type_catalog.py) : game_dashboard reste atteignable
|
||||
# comme pour "custom", mais routes/games/game_dashboard.py y rend
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+2
-1
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
+15
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
+107
-76
@@ -20,94 +20,125 @@ 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 .assert_not_none import assert_not_none
|
||||
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.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 .quests.constants import (
|
||||
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS,
|
||||
)
|
||||
from .quests.list_quests import list_quests
|
||||
from .quests.get_quest import get_quest
|
||||
from .quests.create_quest import create_quest
|
||||
from .quests.update_quest import update_quest
|
||||
from .quests.delete_quest import delete_quest
|
||||
from .quests.set_quest_dialogues import set_quest_dialogues
|
||||
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES
|
||||
from .quests.reward_budget import quest_dialogues_total_reward
|
||||
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",
|
||||
"assert_not_none",
|
||||
"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",
|
||||
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS",
|
||||
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
|
||||
"set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward",
|
||||
"list_custom_events",
|
||||
"get_custom_event",
|
||||
"create_custom_event",
|
||||
"update_custom_event",
|
||||
"sanitize_dialogue_lines",
|
||||
"QUESTION_REWARD_TYPES",
|
||||
"sum_question_rewards",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from typing import TypeVar
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def assert_not_none(value: _T | None, message: str) -> _T:
|
||||
"""Narrowing de type explicite : lève un AssertionError clair si `value`
|
||||
est None, renvoie `value` (type non-Optional) sinon. Centralise ici
|
||||
l'unique suppression Bandit/Ruff de ce type dans tout le moteur - un
|
||||
`assert` direct à chaque site d'appel dupliquait le même commentaire
|
||||
de suppression à chaque fois (voir CODE_QUALITY.md, Sonar python:S7632 :
|
||||
Sonar ne reconnaît pas deux commentaires # empilés sur une ligne, même
|
||||
si Ruff et Bandit les acceptent chacun très bien)."""
|
||||
assert value is not None, message # nosec B101 # noqa: S101 - narrowing de type, seule assert du genre ici
|
||||
return value
|
||||
+17
-17
@@ -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()
|
||||
|
||||
@@ -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/<owner>/<projet>/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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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()
|
||||
@@ -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 à
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,108 @@
|
||||
from typing import Any
|
||||
|
||||
_MAX_LINES_PER_COLUMN = 200
|
||||
_MAX_SPEAKER_LENGTH = 60
|
||||
|
||||
# Question à choix (voir "❓ Question", static/js/triggers/trigger-editor.js) :
|
||||
# une bulle jaune posée dans le même enchaînement qu'une réplique de
|
||||
# dialogue — plusieurs choix, une seule bonne réponse, une récompense.
|
||||
# Un seul type de récompense pour l'instant (le score, voir db.SCORE_STATUS_*/
|
||||
# db.set_score_value) — liste plutôt qu'un booléen pour pouvoir en ajouter
|
||||
# d'autres plus tard sans changer la forme des données.
|
||||
QUESTION_REWARD_TYPES = ["score"]
|
||||
_MIN_CHOICES = 2
|
||||
_MAX_CHOICES = 4
|
||||
|
||||
|
||||
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
|
||||
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 <select>, pas un
|
||||
# champ texte libre, voir static/js/triggers/trigger-editor.js).
|
||||
# Optionnel : une réplique reste valide sans voix off.
|
||||
audio_url = raw_line.get("audio_url")
|
||||
if isinstance(audio_url, str) and audio_url.strip():
|
||||
line["audio_url"] = audio_url.strip()
|
||||
return line
|
||||
|
||||
|
||||
def _sanitize_question_line(raw_line: dict[str, Any]) -> dict[str, Any] | None:
|
||||
text = raw_line.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return None
|
||||
raw_choices = raw_line.get("choices")
|
||||
if not isinstance(raw_choices, list):
|
||||
return None
|
||||
choices = [c.strip() for c in raw_choices if isinstance(c, str) and c.strip()][:_MAX_CHOICES]
|
||||
if len(choices) < _MIN_CHOICES:
|
||||
return None
|
||||
raw_correct_index = raw_line.get("correct_index")
|
||||
if raw_correct_index is None:
|
||||
return None
|
||||
try:
|
||||
correct_index = int(raw_correct_index)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (0 <= correct_index < len(choices)):
|
||||
return None
|
||||
reward_type = raw_line.get("reward_type")
|
||||
if reward_type not in QUESTION_REWARD_TYPES:
|
||||
reward_type = QUESTION_REWARD_TYPES[0]
|
||||
try:
|
||||
reward_amount = max(0, int(raw_line.get("reward_amount", 0)))
|
||||
except (TypeError, ValueError):
|
||||
reward_amount = 0
|
||||
return {
|
||||
"type": "question",
|
||||
"text": text.strip(),
|
||||
"choices": choices,
|
||||
"correct_index": correct_index,
|
||||
"reward_type": reward_type,
|
||||
"reward_amount": reward_amount,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_line(raw_line: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(raw_line, dict):
|
||||
return None
|
||||
if raw_line.get("type") == "question":
|
||||
return _sanitize_question_line(raw_line)
|
||||
return _sanitize_dialogue_line(raw_line)
|
||||
|
||||
|
||||
def sanitize_dialogue_lines(raw_lines: Any) -> list[dict[str, Any]]:
|
||||
"""Sanitize une LISTE de répliques de dialogue ({type: "dialogue",
|
||||
speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de
|
||||
scène, ou "Joueur", voir screens/rendering/scene_object_names.py)
|
||||
ET/OU de questions à choix ({type: "question", text, choices,
|
||||
correct_index, reward_type, reward_amount}, voir QUESTION_REWARD_TYPES
|
||||
ci-dessus) — même esprit que resolve_collision_rules.sanitize_collision_rules :
|
||||
rejette tout élément invalide plutôt que de lever. Les lignes d'UN
|
||||
dialogue embarqué dans une action "dialogue" (voir
|
||||
screens/rendering/collision_rules.py) : chaque occurrence de
|
||||
"Déclencher ce dialogue" porte le sien."""
|
||||
if not isinstance(raw_lines, list):
|
||||
return []
|
||||
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
|
||||
return [line for line in cleaned if line]
|
||||
|
||||
|
||||
def sum_question_rewards(lines: Any) -> int:
|
||||
"""Somme des récompenses de toutes les questions ("❓ Question" ci-
|
||||
dessus) d'UNE liste de répliques/questions — utilisée pour calculer le
|
||||
score max possible du quiz à l'export SCORM, sur TOUTES les lignes de
|
||||
TOUS les dialogues du jeu (voir screens.collect_all_dialogue_lines,
|
||||
publish/build_scorm_package.py)."""
|
||||
total = 0
|
||||
for line in lines or []:
|
||||
if isinstance(line, dict) and line.get("type") == "question":
|
||||
total += line.get("reward_amount", 0) or 0
|
||||
return total
|
||||
+1
-1
@@ -4,7 +4,7 @@ from .constants import PROJECTS_DIR
|
||||
from .games.project_slug import split_slug
|
||||
|
||||
|
||||
def game_dir(slug):
|
||||
def game_dir(slug: str) -> str:
|
||||
# Structure de dossiers par utilisateur (voir db/games/project_slug.py)
|
||||
# : un slug composé "propriétaire_projet" résout vers un vrai chemin
|
||||
# imbriqué projects/<propriétaire>/<projet>/ — repli sur l'ancien
|
||||
|
||||
@@ -6,13 +6,14 @@ from ..slugify import slugify
|
||||
from .project_slug import build_slug
|
||||
|
||||
|
||||
def create_game(name, owner_folder=None, project_slug_override=None):
|
||||
def create_game(name: str, owner_folder: str | None = None, project_slug_override: str | None = None) -> str:
|
||||
"""Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js
|
||||
reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
|
||||
|
||||
owner_folder : dossier PROPRIÉTAIRE (voir db/games/project_slug.py —
|
||||
structure de dossiers par utilisateur), typiquement
|
||||
slugify(email_du_compte) — le slug final composé
|
||||
structure de dossiers par utilisateur), l'id du compte en pratique
|
||||
(voir routes/games/games_new.py — opaque, jamais dérivé de l'email) —
|
||||
le slug final composé
|
||||
"<owner_folder>_<project_part>" résout vers un vrai chemin imbriqué
|
||||
projects/<owner_folder>/<project_part>/. Omis (None) : repli sur
|
||||
l'ancien comportement plat (slug à un seul segment, projects/<slug>/)
|
||||
|
||||
@@ -6,7 +6,7 @@ from ..game_dir import game_dir
|
||||
from .project_slug import split_slug
|
||||
|
||||
|
||||
def delete_game(slug):
|
||||
def delete_game(slug: str) -> None:
|
||||
shutil.rmtree(game_dir(slug))
|
||||
# Structure de dossiers par utilisateur (voir project_slug.py) :
|
||||
# nettoie aussi le dossier propriétaire s'il ne contient plus aucun
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .game_type_catalog import get_onboarding_type
|
||||
|
||||
|
||||
def game_meta(slug):
|
||||
def game_meta(slug: str) -> dict[str, Any]:
|
||||
conn = connect(slug)
|
||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
|
||||
conn.close()
|
||||
return {
|
||||
"slug": slug, "name": row["value"] if row else slug,
|
||||
"slug": slug,
|
||||
"name": row["value"] if row else slug,
|
||||
# Onboarding guidé (voir game_type_catalog.py) : utilisé par
|
||||
# templates/base.html pour cacher le lien "Tableau de bord" à un
|
||||
# compte "restreint" (quiz/embranchement/rpg).
|
||||
|
||||
@@ -13,11 +13,14 @@ _meta['onboarding_type'] (une ligne _meta par projet, même convention que
|
||||
game_type) retient CE choix — jamais lu par le
|
||||
rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
|
||||
routes/screens/screens_new.py)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import db
|
||||
|
||||
DEFAULT_ONBOARDING_TYPE = "rpg"
|
||||
|
||||
ONBOARDING_TYPES = {
|
||||
ONBOARDING_TYPES: dict[str, dict[str, Any]] = {
|
||||
"rpg": {
|
||||
"label": "Créer un jeu 2D ludique",
|
||||
"tagline": "Ton monde, ton héros, ton scénario.",
|
||||
@@ -57,7 +60,7 @@ ONBOARDING_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def get_onboarding_type_raw(slug):
|
||||
def get_onboarding_type_raw(slug: str) -> str | None:
|
||||
"""None si _meta['onboarding_type'] est absent — distingue un projet
|
||||
JAMAIS passé par l'onboarding guidé (créé avant son existence, ou par
|
||||
l'admin via "+ Nouveau jeu") d'un projet explicitement "custom"."""
|
||||
@@ -67,11 +70,11 @@ def get_onboarding_type_raw(slug):
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
def get_onboarding_type(slug):
|
||||
def get_onboarding_type(slug: str) -> str:
|
||||
return get_onboarding_type_raw(slug) or DEFAULT_ONBOARDING_TYPE
|
||||
|
||||
|
||||
def set_onboarding_type(slug, onboarding_type):
|
||||
def set_onboarding_type(slug: str, onboarding_type: str) -> None:
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)",
|
||||
@@ -81,15 +84,14 @@ def set_onboarding_type(slug, onboarding_type):
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_restricted(onboarding_type_or_slug):
|
||||
def is_restricted(onboarding_type_or_slug: str) -> bool:
|
||||
"""Accepte directement une clé de ONBOARDING_TYPES, ou un slug de
|
||||
projet (résout alors son onboarding_type d'abord) — pratique aussi
|
||||
bien pour core/auth_guard.py (a le slug) que pour un test unitaire (a
|
||||
déjà la clé)."""
|
||||
onboarding_type = (
|
||||
onboarding_type_or_slug if onboarding_type_or_slug in ONBOARDING_TYPES
|
||||
onboarding_type_or_slug
|
||||
if onboarding_type_or_slug in ONBOARDING_TYPES
|
||||
else get_onboarding_type(onboarding_type_or_slug)
|
||||
)
|
||||
return ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"]
|
||||
|
||||
|
||||
return bool(ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"])
|
||||
|
||||
@@ -3,7 +3,7 @@ from ..connection import connect
|
||||
DEFAULT_GAME_TYPE = "jeu_2d"
|
||||
|
||||
|
||||
def get_game_type(slug):
|
||||
def get_game_type(slug: str) -> str:
|
||||
"""Type de jeu choisi à la création (voir create_game.py) : "document"
|
||||
(éditeur générique actuel — quiz/formulaires/contenus, écrans =
|
||||
éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié —
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from ..connection import connect
|
||||
|
||||
DEFAULT_SCORM_VERSION = "1.2"
|
||||
VALID_SCORM_VERSIONS = ("1.2", "2004")
|
||||
|
||||
|
||||
def get_scorm_version(slug: str) -> str:
|
||||
"""Version SCORM exportée pour ce jeu (voir publish/build_scorm_package.py,
|
||||
publish/scorm_manifest.py) — '1.2' par défaut (compatibilité la plus
|
||||
large) ou '2004' (3rd/4th edition, sépare completion_status/
|
||||
success_status : voir static/js/play/offline/scorm2004-api.js, corrige
|
||||
la limite SCORM 1.2 où un statut unique doit coder à la fois
|
||||
complétion ET réussite)."""
|
||||
conn = connect(slug)
|
||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'scorm_version'").fetchone()
|
||||
conn.close()
|
||||
if row is None or row["value"] not in VALID_SCORM_VERSIONS:
|
||||
return DEFAULT_SCORM_VERSION
|
||||
return str(row["value"])
|
||||
@@ -0,0 +1,17 @@
|
||||
from ..connection import connect
|
||||
|
||||
DEFAULT_SUCCESS_THRESHOLD_PERCENT = 70
|
||||
|
||||
|
||||
def get_success_threshold(slug: str) -> int:
|
||||
"""Seuil de réussite (% de bonnes réponses au quiz) de CE jeu — stocké
|
||||
dans _meta (clé 'success_threshold_percent'), même convention que
|
||||
get_xapi_settings.py. Détermine le statut SCORM/xAPI de fin de partie
|
||||
(reussi/echoue, voir static/js/play/dialogue-box-controller.js::
|
||||
forgeSyncAllQuestsCompletionToScorm) : 70% par défaut si jamais réglé."""
|
||||
conn = connect(slug)
|
||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'success_threshold_percent'").fetchone()
|
||||
conn.close()
|
||||
if row is None or row["value"] in (None, ""):
|
||||
return DEFAULT_SUCCESS_THRESHOLD_PERCENT
|
||||
return int(row["value"])
|
||||
@@ -0,0 +1,23 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def get_xapi_settings(slug: str) -> dict[str, str]:
|
||||
"""Réglages xAPI de CE jeu (voir set_xapi_settings.py — stockés dans
|
||||
_meta, même convention que 'name'/'onboarding_type', voir
|
||||
db/games/game_type_catalog.py) : URL du LRS (Learning Record Store)
|
||||
+ identifiants, saisis une seule fois par le créateur du jeu (voir
|
||||
routes/publish/xapi_settings.py) plutôt que négociés à chaque
|
||||
lancement (jamais le protocole cmi5 complet, voir publish/
|
||||
build_scorm_package.py::build_scorm_zip). Chaînes vides si non
|
||||
configuré — `endpoint` vide signifie "xAPI désactivé pour ce jeu"."""
|
||||
conn = connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT key, value FROM _meta WHERE key IN ('xapi_lrs_endpoint', 'xapi_lrs_login', 'xapi_lrs_password')"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
values = {row["key"]: row["value"] for row in rows}
|
||||
return {
|
||||
"endpoint": values.get("xapi_lrs_endpoint") or "",
|
||||
"login": values.get("xapi_lrs_login") or "",
|
||||
"password": values.get("xapi_lrs_password") or "",
|
||||
}
|
||||
+21
-22
@@ -1,36 +1,35 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from ..constants import PROJECTS_DIR
|
||||
from ..connection import connect
|
||||
from ..constants import PROJECTS_DIR
|
||||
from ..db_path import db_path
|
||||
from .project_slug import build_slug
|
||||
|
||||
|
||||
def list_games():
|
||||
"""Scanne projects/ : structure par utilisateur (voir
|
||||
db/games/project_slug.py) — projects/<propriétaire>/<projet>/game.db,
|
||||
deux niveaux — plus le repli sur l'ancien rangement plat
|
||||
projects/<slug>/game.db pour tout slug pas encore migré (voir
|
||||
scripts/migrate_flat_project_slugs.py)."""
|
||||
games = []
|
||||
if not os.path.isdir(PROJECTS_DIR):
|
||||
def list_games(owner_folder: str) -> list[dict[str, Any]]:
|
||||
"""Scanne UNIQUEMENT projects/<owner_folder>/ (voir db/games/
|
||||
project_slug.py — owner_folder est l'id du compte, voir
|
||||
db/games/create_game.py) : jamais les autres comptes — voir
|
||||
core/auth_guard.py, qui vérifie déjà que `owner_folder` correspond au
|
||||
compte connecté avant tout accès à un slug composé de cette valeur.
|
||||
Un appelant qui passerait le mauvais owner_folder ne verrait de toute
|
||||
façon que SES PROPRES projets, jamais ceux d'un autre compte : cette
|
||||
fonction ne lit plus jamais l'arborescence project/ en entier (avant
|
||||
ce correctif, elle listait TOUS les comptes sans distinction — faille
|
||||
corrigée, voir routes/games/index.py)."""
|
||||
games: list[dict[str, Any]] = []
|
||||
entry_path = os.path.join(PROJECTS_DIR, owner_folder)
|
||||
if not os.path.isdir(entry_path):
|
||||
return games
|
||||
for entry in sorted(os.listdir(PROJECTS_DIR)):
|
||||
entry_path = os.path.join(PROJECTS_DIR, entry)
|
||||
if not os.path.isdir(entry_path):
|
||||
continue
|
||||
if os.path.isfile(os.path.join(entry_path, "game.db")):
|
||||
# Repli : un ancien dossier plat porte directement game.db.
|
||||
_append_game(games, entry)
|
||||
continue
|
||||
for project_part in sorted(os.listdir(entry_path)):
|
||||
slug = build_slug(entry, project_part)
|
||||
if os.path.isfile(db_path(slug)):
|
||||
_append_game(games, slug)
|
||||
for project_part in sorted(os.listdir(entry_path)):
|
||||
slug = build_slug(owner_folder, project_part)
|
||||
if os.path.isfile(db_path(slug)):
|
||||
_append_game(games, slug)
|
||||
return games
|
||||
|
||||
|
||||
def _append_game(games, slug):
|
||||
def _append_game(games: list[dict[str, Any]], slug: str) -> None:
|
||||
conn = connect(slug)
|
||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
|
||||
conn.close()
|
||||
|
||||
+13
-12
@@ -5,18 +5,19 @@ from ..game_dir import game_dir
|
||||
from .project_slug import build_slug, split_slug
|
||||
|
||||
|
||||
def move_game(old_slug, new_owner_folder):
|
||||
"""Renomme le dossier PROPRIÉTAIRE d'un compte — utilisé quand un
|
||||
utilisateur change son adresse email (voir routes/auth/profile.py,
|
||||
owner_folder = slugify(email) pour un compte "user", voir
|
||||
db/games/project_slug.py) : le dossier physique doit suivre. Renomme
|
||||
le dossier propriétaire ENTIER en un coup (déplace tous les projets de
|
||||
ce compte ensemble — prêt pour un futur multi-projet, même si un seul
|
||||
aujourd'hui), pas juste un projet. Rend le nouveau dossier propriétaire
|
||||
unique de la même façon que create_game() si, par un hasard extrême,
|
||||
il correspond déjà à un dossier existant. Renvoie le nouveau slug
|
||||
composé FINAL (même projet, propriétaire renommé), à enregistrer comme
|
||||
nouveau project_slug."""
|
||||
def move_game(old_slug: str, new_owner_folder: str) -> str:
|
||||
"""Renomme le dossier PROPRIÉTAIRE d'un compte — owner_folder est
|
||||
l'id du compte (voir db/games/project_slug.py, routes/games/
|
||||
games_new.py) : ne change donc plus jamais après coup en usage normal,
|
||||
mais reste utile pour une migration ponctuelle (voir
|
||||
scripts/migrate_owner_folders_to_user_id.py, qui a besoin de renommer
|
||||
les anciens dossiers slugify(email) vers l'id du compte). Renomme le
|
||||
dossier propriétaire ENTIER en un coup (déplace tous les projets de ce
|
||||
compte ensemble), pas juste un projet. Rend le nouveau dossier
|
||||
propriétaire unique de la même façon que create_game() si, par un
|
||||
hasard extrême, il correspond déjà à un dossier existant. Renvoie le
|
||||
nouveau slug composé FINAL (même projet, propriétaire renommé), à
|
||||
enregistrer comme nouveau project_slug."""
|
||||
old_owner_folder, project_part = split_slug(old_slug)
|
||||
if project_part is None:
|
||||
# Slug pas encore migré vers la structure par utilisateur (voir
|
||||
|
||||
@@ -17,11 +17,11 @@ move_game, list_games) savent que le slug encode ce chemin composé — si
|
||||
la convention change un jour, ce fichier est le seul à modifier."""
|
||||
|
||||
|
||||
def build_slug(owner_folder, project_part):
|
||||
def build_slug(owner_folder: str, project_part: str) -> str:
|
||||
return f"{owner_folder}_{project_part}"
|
||||
|
||||
|
||||
def split_slug(slug):
|
||||
def split_slug(slug: str) -> tuple[str, str | None]:
|
||||
"""(owner_folder, project_part) si `slug` est bien composé, sinon
|
||||
(slug, None) — un slug "plat" (créé avant cette convention, pas encore
|
||||
migré par scripts/migrate_flat_project_slugs.py) reste lisible tel
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from ..connection import connect
|
||||
from .get_scorm_version import VALID_SCORM_VERSIONS
|
||||
|
||||
|
||||
def set_scorm_version(slug: str, version: str) -> None:
|
||||
"""Enregistre la version SCORM exportée — voir get_scorm_version.py."""
|
||||
if version not in VALID_SCORM_VERSIONS:
|
||||
raise ValueError("version SCORM invalide : {!r}".format(version))
|
||||
conn = connect(slug)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('scorm_version', ?)",
|
||||
(version,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,14 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def set_success_threshold(slug: str, percent: int) -> int:
|
||||
"""Enregistre le seuil de réussite (0-100) — voir get_success_threshold.py."""
|
||||
percent = max(0, min(100, int(percent)))
|
||||
conn = connect(slug)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('success_threshold_percent', ?)",
|
||||
(str(percent),),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return percent
|
||||
@@ -0,0 +1,18 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def set_xapi_settings(slug: str, endpoint: str, login: str, password: str | None = None) -> None:
|
||||
"""Enregistre les réglages xAPI de CE jeu (voir get_xapi_settings.py).
|
||||
`password=None` (champ laissé vide côté formulaire, voir
|
||||
routes/publish/xapi_settings.py) laisse le mot de passe déjà
|
||||
enregistré INCHANGÉ — jamais écrasé par une chaîne vide, pour ne pas
|
||||
obliger à le retaper à chaque modification de l'URL/du login, et pour
|
||||
que la route GET puisse ne jamais renvoyer sa valeur au navigateur
|
||||
(juste un booléen "déjà configuré")."""
|
||||
conn = connect(slug)
|
||||
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",))
|
||||
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_login', ?)", (login or "",))
|
||||
if password is not None:
|
||||
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def update_game_name(slug, new_name):
|
||||
def update_game_name(slug: str, new_name: str) -> None:
|
||||
conn = connect(slug)
|
||||
conn.execute("UPDATE _meta SET value = ? WHERE key = 'name'", (new_name,))
|
||||
conn.commit()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
|
||||
|
||||
def coerce_structured_value(var_type, value):
|
||||
def coerce_structured_value(var_type: str, value: str) -> str:
|
||||
"""Pour une variable "objet"/"tableau" (voir db/constants.py), la
|
||||
valeur stockée doit rester du JSON analysable — sinon la prochaine
|
||||
lecture (_resolve_variable_path, screens/rendering/
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
|
||||
from .coerce_structured_value import coerce_structured_value
|
||||
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
|
||||
|
||||
|
||||
def create_global_variable(slug, name, var_type="texte", default_value="", per_player=True):
|
||||
def create_global_variable(
|
||||
slug: str, name: str, var_type: str = "texte", default_value: str = "", per_player: bool = True
|
||||
) -> int | None:
|
||||
"""Crée la ligne "modèle" d'une variable globale (toujours
|
||||
player_id=PLAYER_SHARED, quel que soit per_player — voir
|
||||
ensure_global_vars_schema.py) si son nom n'existe pas déjà ; si elle
|
||||
@@ -28,12 +30,12 @@ def create_global_variable(slug, name, var_type="texte", default_value="", per_p
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.close()
|
||||
return existing["id"]
|
||||
return int(existing["id"])
|
||||
conn.execute(
|
||||
"INSERT INTO _global_variables (name, type, value, player_id, per_player) VALUES (?, ?, ?, ?, ?)",
|
||||
(name, var_type, default_value, PLAYER_SHARED, 1 if per_player else 0),
|
||||
)
|
||||
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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user