Compare commits
2
Commits
7db4803b93
...
2ff127f68e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff127f68e | ||
|
|
c57420c8c9 |
@@ -45,3 +45,9 @@ ANTHROPIC_API_KEY=
|
||||
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
|
||||
@@ -4,12 +4,26 @@ on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
|
||||
# Le job "test" tourne sur CHAQUE push (main et dev) : jusqu'ici aucune
|
||||
# étape de CI n'exécutait la suite de tests, rien n'empêchait un commit
|
||||
# cassé d'atteindre la production (voir l'audit qualité de la Phase 0 du
|
||||
# plan). "build-and-push"/"deploy", eux, restent réservés à main (via le
|
||||
# filtre "if" sur gitea.ref) — un push sur dev ne doit jamais redéployer
|
||||
# la prod, seulement faire tourner les tests.
|
||||
# Les jobs "test-*"/"lint-*"/"sonarqube" tournent sur CHAQUE push (main et
|
||||
# dev) : jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les
|
||||
# outils qualité, rien n'empêchait un commit cassé d'atteindre la
|
||||
# production (voir l'audit qualité de la Phase 0 du plan). "build-and-
|
||||
# push"/"deploy", eux, restent réservés à main (via le filtre "if" sur
|
||||
# gitea.ref) — un push sur dev ne doit jamais redéployer la prod, seulement
|
||||
# faire tourner tests/lint/sonar.
|
||||
#
|
||||
# lint-python/lint-js rejouent EXACTEMENT les hooks pre-commit locaux
|
||||
# (.pre-commit-config.yaml) mais bloquants ici dès le départ (déjà tous
|
||||
# verts en local, voir le commit "Phase 3 : hardening qualite de code") —
|
||||
# les hooks pre-commit ne protègent que la machine du committeur, jamais
|
||||
# un push direct ou une PR mergée depuis ailleurs. djlint EXCLU
|
||||
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
|
||||
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube,
|
||||
# lui, reste NON-BLOQUANT (continue-on-error) pendant cette première
|
||||
# période — voir CODE_QUALITY.md pour la trajectoire vers un mode
|
||||
# bloquant une fois le rapport trié (code smells, vulnerabilites) plutôt
|
||||
# que de bloquer tout de suite sur des centaines de signalements pas
|
||||
# encore triés.
|
||||
|
||||
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
|
||||
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
|
||||
@@ -20,6 +34,8 @@ on:
|
||||
# DEPLOY_USER utilisateur SSH sur ce serveur
|
||||
# DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur
|
||||
# DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine)
|
||||
# SONAR_TOKEN jeton d'analyse SonarQube (Mon compte > Security > Generate Token
|
||||
# sur sonar.forgebase.fr) — jamais le mot de passe admin.
|
||||
#
|
||||
# Utilise directement docker/ssh/scp en ligne de commande plutôt que des
|
||||
# actions du marketplace, pour ne pas dépendre de la disponibilité de
|
||||
@@ -79,8 +95,70 @@ jobs:
|
||||
RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
|
||||
DOCKERFILE
|
||||
|
||||
lint-python:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Qualite Python (ruff/mypy/vulture/bandit/import-linter — memes commandes que .pre-commit-config.yaml)
|
||||
run: |
|
||||
# Meme neutralisation de .dockerignore que test-python ci-dessus :
|
||||
# ruff/mypy typent aussi tests/ (voir le plan de typage strict),
|
||||
# exclu par defaut de l'image de PROD.
|
||||
mv .dockerignore .dockerignore.disabled-for-ci
|
||||
docker build -f - -t forge-lint-python:${{ gitea.sha }} . <<'DOCKERFILE'
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt requirements-dev.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
COPY . .
|
||||
RUN ruff check .
|
||||
RUN ruff format --check .
|
||||
RUN mypy .
|
||||
RUN vulture
|
||||
RUN bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py
|
||||
RUN lint-imports
|
||||
DOCKERFILE
|
||||
|
||||
lint-js:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Qualite JS/CSS (eslint/stylelint — memes commandes que .pre-commit-config.yaml, via les scripts npm de package.json)
|
||||
run: |
|
||||
docker build -f - -t forge-lint-js:${{ gitea.sha }} . <<'DOCKERFILE'
|
||||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run lint:js
|
||||
RUN npm run lint:css
|
||||
DOCKERFILE
|
||||
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
# Non-bloquant pendant cette premiere periode (voir le bloc de
|
||||
# commentaires en tete de fichier) — un echec ici n'empeche jamais
|
||||
# build-and-push/deploy, contrairement a lint-python/lint-js.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Analyse SonarQube (rapport seul, non-bloquant)
|
||||
run: |
|
||||
# Meme neutralisation de .dockerignore que test-python : sonar-
|
||||
# project.properties couvre aussi tests/ (sonar.tests).
|
||||
mv .dockerignore .dockerignore.disabled-for-ci
|
||||
docker build -f - -t forge-sonar:${{ gitea.sha }} . <<'DOCKERFILE'
|
||||
FROM sonarsource/sonar-scanner-cli:latest
|
||||
WORKDIR /usr/src
|
||||
COPY . .
|
||||
DOCKERFILE
|
||||
docker run --rm forge-sonar:${{ gitea.sha }} \
|
||||
-Dsonar.host.url=https://sonar.forgebase.fr \
|
||||
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
build-and-push:
|
||||
needs: [test-python, test-js]
|
||||
needs: [test-python, test-js, lint-python, lint-js]
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
+10
@@ -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/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+11
-5
@@ -3,13 +3,19 @@ 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 .tools import TOOLS, dispatch_tool
|
||||
from .client import get_client, AnthropicNotConfiguredError, MODEL
|
||||
from .scenario_client import generate_image_url, ScenarioNotConfiguredError, ScenarioGenerationError
|
||||
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",
|
||||
"TOOLS",
|
||||
"dispatch_tool",
|
||||
"get_client",
|
||||
"AnthropicNotConfiguredError",
|
||||
"MODEL",
|
||||
"generate_image_url",
|
||||
"ScenarioNotConfiguredError",
|
||||
"ScenarioGenerationError",
|
||||
"run_chat_turn",
|
||||
]
|
||||
|
||||
+97
-65
@@ -2,12 +2,14 @@
|
||||
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 get_client, MODEL
|
||||
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
|
||||
@@ -25,10 +27,10 @@ _SYSTEM_PROMPT = (
|
||||
"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é\" "
|
||||
'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 "
|
||||
'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é). "
|
||||
@@ -40,10 +42,10 @@ _SYSTEM_PROMPT = (
|
||||
"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, "
|
||||
'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 "
|
||||
'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 "
|
||||
@@ -51,29 +53,29 @@ _SYSTEM_PROMPT = (
|
||||
"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é, "
|
||||
'"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 "
|
||||
'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é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\" "
|
||||
'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\" "
|
||||
'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 "
|
||||
'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)."
|
||||
@@ -97,13 +99,13 @@ _SYSTEM_PROMPT = (
|
||||
"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 "
|
||||
'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\" : "
|
||||
'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 — "
|
||||
@@ -112,37 +114,37 @@ _SYSTEM_PROMPT = (
|
||||
"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 "
|
||||
'"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, "
|
||||
'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 "
|
||||
'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 "
|
||||
'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 "
|
||||
'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 "
|
||||
'(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\" "
|
||||
'"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 "
|
||||
@@ -152,31 +154,31 @@ _SYSTEM_PROMPT = (
|
||||
"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 "
|
||||
'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 "
|
||||
'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 "
|
||||
'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 "
|
||||
'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) "
|
||||
'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 "
|
||||
@@ -194,13 +196,26 @@ _SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
def _describe_scene_state(slug, screen_id):
|
||||
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
|
||||
@@ -251,11 +266,11 @@ def _describe_scene_state(slug, screen_id):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _history_to_messages(history):
|
||||
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, screen_id, conversation_id, user_id, user_message):
|
||||
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
|
||||
@@ -264,40 +279,57 @@ def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message):
|
||||
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)."""
|
||||
client = get_client() # AnthropicNotConfiguredError si pas de clé
|
||||
messages = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id))
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
try:
|
||||
# État réel de la scène RE-LU à chaque tour (jamais mémorisé par
|
||||
# Claude lui-même) — voir _describe_scene_state.
|
||||
system_prompt = _SYSTEM_PROMPT + "\n\n" + _describe_scene_state(slug, screen_id)
|
||||
# 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):
|
||||
response = client.messages.create(
|
||||
# 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).
|
||||
model=MODEL, max_tokens=16000, system=system_prompt,
|
||||
tools=TOOLS, thinking={"type": "adaptive"}, messages=messages,
|
||||
# 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 = []
|
||||
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),
|
||||
})
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": json.dumps(result),
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
|
||||
assert response is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
||||
"_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois"
|
||||
)
|
||||
text = next((b.text for b in response.content if b.type == "text"), "")
|
||||
if text:
|
||||
return text
|
||||
|
||||
+3
-4
@@ -1,6 +1,7 @@
|
||||
"""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
|
||||
@@ -14,10 +15,8 @@ class AnthropicNotConfiguredError(Exception):
|
||||
il faut un compte Anthropic Console (console.anthropic.com) séparé."""
|
||||
|
||||
|
||||
def get_client():
|
||||
def get_client() -> anthropic.Anthropic:
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
raise AnthropicNotConfiguredError(
|
||||
"ANTHROPIC_API_KEY absente"
|
||||
)
|
||||
raise AnthropicNotConfiguredError("ANTHROPIC_API_KEY absente")
|
||||
return anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
|
||||
@@ -28,7 +29,7 @@ class ScenarioGenerationError(Exception):
|
||||
"""Le job Scenario a échoué ou n'a pas répondu à temps."""
|
||||
|
||||
|
||||
def _credentials():
|
||||
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")
|
||||
@@ -39,14 +40,18 @@ def _credentials():
|
||||
return (api_key, api_secret), model_id
|
||||
|
||||
|
||||
def generate_image_url(prompt, width=768, height=768, timeout=120, poll_interval=2):
|
||||
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,
|
||||
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()
|
||||
@@ -61,7 +66,7 @@ def generate_image_url(prompt, width=768, height=768, timeout=120, poll_interval
|
||||
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 asset_response.json()["asset"]["url"]
|
||||
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)
|
||||
|
||||
+204
-80
@@ -20,6 +20,7 @@ DATA_OPERATION_LABELS, ...) plutôt que recopiées en dur. Un test dédié
|
||||
remplacée par une copie littérale."""
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
import requests
|
||||
from flask import url_for
|
||||
@@ -28,12 +29,13 @@ import auth
|
||||
import db
|
||||
import screens
|
||||
from db.dialogue_lines import QUESTION_REWARD_TYPES
|
||||
from screens.rendering.collision_rules import ACTION_TYPES, LEAF_ACTION_TYPES, CONDITION_OPERATOR_KEYS, TRIGGER_TYPES
|
||||
from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN
|
||||
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)
|
||||
@@ -53,37 +55,49 @@ _GLOBAL_VARIABLE_TYPES = sorted(db.GLOBAL_VARIABLE_TYPES)
|
||||
_FORGE_CHARACTERS = sorted(screens.SPRITE_LIBRARY)
|
||||
_BACKGROUNDS = sorted(screens.BACKGROUND_LIBRARY)
|
||||
|
||||
_LEAF_ACTION_SCHEMA = {
|
||||
_LEAF_ACTION_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Une action FEUILLE (\"dialogue\" ou \"variable\", voir "
|
||||
'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)."
|
||||
'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."},
|
||||
"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>}}. "
|
||||
'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 "
|
||||
'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."
|
||||
'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."},
|
||||
"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\")."},
|
||||
"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": (
|
||||
@@ -102,7 +116,7 @@ _LEAF_ACTION_SCHEMA = {
|
||||
},
|
||||
"asset_url": {
|
||||
"type": "string",
|
||||
"description": "Pour type=son/video : URL d'un fichier déjà présent dans \"Mes assets\".",
|
||||
"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."},
|
||||
@@ -112,9 +126,11 @@ _LEAF_ACTION_SCHEMA = {
|
||||
"required": ["type"],
|
||||
}
|
||||
|
||||
_ACTION_SCHEMA = {
|
||||
_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.",
|
||||
"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"},
|
||||
@@ -129,8 +145,12 @@ _ACTION_SCHEMA = {
|
||||
"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)."},
|
||||
"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."},
|
||||
@@ -139,21 +159,43 @@ _ACTION_SCHEMA = {
|
||||
"required": ["type"],
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
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\", "
|
||||
'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."},
|
||||
"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"],
|
||||
},
|
||||
@@ -165,8 +207,10 @@ TOOLS = [
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"x": {"type": "number"}, "y": {"type": "number"},
|
||||
"width": {"type": "number"}, "height": {"type": "number"},
|
||||
"x": {"type": "number"},
|
||||
"y": {"type": "number"},
|
||||
"width": {"type": "number"},
|
||||
"height": {"type": "number"},
|
||||
},
|
||||
"required": ["object_id", "x", "y", "width", "height"],
|
||||
},
|
||||
@@ -174,8 +218,8 @@ TOOLS = [
|
||||
{
|
||||
"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."
|
||||
'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",
|
||||
@@ -207,8 +251,10 @@ TOOLS = [
|
||||
"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"},
|
||||
"width": {"type": "number"},
|
||||
"height": {"type": "number"},
|
||||
"offset_x": {"type": "number"},
|
||||
"offset_y": {"type": "number"},
|
||||
},
|
||||
"required": ["object_id"],
|
||||
},
|
||||
@@ -216,13 +262,13 @@ TOOLS = [
|
||||
{
|
||||
"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 "
|
||||
'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 "
|
||||
'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."
|
||||
),
|
||||
@@ -232,16 +278,29 @@ TOOLS = [
|
||||
"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."},
|
||||
"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.",
|
||||
"description": (
|
||||
"Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -256,10 +315,10 @@ TOOLS = [
|
||||
{
|
||||
"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 "
|
||||
'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 "
|
||||
@@ -316,7 +375,7 @@ TOOLS = [
|
||||
"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 "
|
||||
'ça) via Scenario, l\'ajoute à "Mes assets" et la pose '
|
||||
"automatiquement sur l'écran en cours."
|
||||
),
|
||||
"input_schema": {
|
||||
@@ -370,9 +429,9 @@ TOOLS = [
|
||||
"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 "
|
||||
'"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": {
|
||||
@@ -397,7 +456,7 @@ TOOLS = [
|
||||
"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 "
|
||||
'("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."
|
||||
),
|
||||
@@ -426,7 +485,9 @@ TOOLS = [
|
||||
]
|
||||
|
||||
|
||||
def _clamp_to_camera(slug, screen_id, kind, x, y, width, height):
|
||||
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
|
||||
@@ -456,7 +517,15 @@ def _clamp_to_camera(slug, screen_id, kind, x, y, width, height):
|
||||
return clamped_x, clamped_y, note
|
||||
|
||||
|
||||
def _dispatch_add_scene_object(slug, screen_id, user_id, kind, forge_character=None, background_slug=None, image_url=None):
|
||||
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
|
||||
@@ -481,59 +550,90 @@ def _dispatch_add_scene_object(slug, screen_id, user_id, kind, forge_character=N
|
||||
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,
|
||||
slug,
|
||||
screen_id,
|
||||
kind=kind,
|
||||
forge_character=forge_character,
|
||||
background_slug=background_slug,
|
||||
image_url=image_url,
|
||||
)
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
||||
"object_id vient d'etre cree par screens.add_scene_object juste au-dessus"
|
||||
)
|
||||
# Position par défaut (100,100, voir ensure_scene_schema.py) déjà
|
||||
# posée par screens.add_scene_object — reste passée par le même
|
||||
# garde-fou pour rester correcte même si ce défaut changeait un jour.
|
||||
clamped_x, clamped_y, note = _clamp_to_camera(slug, screen_id, kind, obj["x"], obj["y"], obj["width"], obj["height"])
|
||||
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 = {"object_id": object_id}
|
||||
result: dict[str, Any] = {"object_id": object_id}
|
||||
if note:
|
||||
result["note"] = note
|
||||
return result
|
||||
|
||||
|
||||
def _dispatch_set_object_geometry(slug, screen_id, user_id, object_id, x, y, width, height):
|
||||
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 = {"ok": True}
|
||||
result: dict[str, Any] = {"ok": True}
|
||||
if note:
|
||||
result["note"] = note
|
||||
return result
|
||||
|
||||
|
||||
def _dispatch_set_object_name(slug, screen_id, user_id, object_id, name):
|
||||
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, screen_id, user_id, object_id, role):
|
||||
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, screen_id, user_id, object_id, enabled=True, shape="rectangle",
|
||||
width=None, height=None, offset_x=0, offset_y=0):
|
||||
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 = {
|
||||
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,
|
||||
"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, screen_id, user_id, object_id, fullscreen=False,
|
||||
timer_mode="aucun", timer_seconds=20,
|
||||
dialog_template="defaut", page_template="classique"):
|
||||
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
|
||||
@@ -541,7 +641,7 @@ def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscree
|
||||
# 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 = {
|
||||
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,
|
||||
@@ -552,54 +652,76 @@ def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscree
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _dispatch_create_global_variable(slug, screen_id, user_id, name, var_type="texte", default_value="", per_player=True):
|
||||
variable_id = db.create_global_variable(slug, name, var_type=var_type, default_value=default_value, per_player=per_player)
|
||||
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, screen_id, user_id, object_id, rules):
|
||||
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, screen_id, user_id, object_id, after_id, action):
|
||||
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, screen_id, user_id, rules):
|
||||
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, screen_id, user_id, after_id, action):
|
||||
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, screen_id, user_id, object_id, leaf_id):
|
||||
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, screen_id, user_id, leaf_id):
|
||||
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, screen_id, user_id, object_id, leaf_id, direction):
|
||||
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, screen_id, user_id, leaf_id, direction):
|
||||
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, screen_id, user_id, kind, prompt):
|
||||
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
|
||||
@@ -615,7 +737,7 @@ def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt):
|
||||
return {"object_id": object_id, "asset_id": asset_id}
|
||||
|
||||
|
||||
_DISPATCH = {
|
||||
_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,
|
||||
@@ -635,7 +757,9 @@ _DISPATCH = {
|
||||
}
|
||||
|
||||
|
||||
def dispatch_tool(slug, screen_id, user_id, tool_name, tool_input):
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+82
-51
@@ -4,63 +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 .password_reset import (
|
||||
create_password_reset_token, get_user_id_for_valid_token, consume_password_reset_token,
|
||||
TOKEN_TTL_MINUTES,
|
||||
)
|
||||
from .send_email import send_password_reset_email, EmailNotConfiguredError
|
||||
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 .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
from .user_assets_dir import user_assets_dir
|
||||
from .create_user_asset import create_user_asset
|
||||
from .list_user_assets import list_user_assets
|
||||
from .get_user_asset import get_user_asset
|
||||
from .delete_user_asset import delete_user_asset
|
||||
from .save_user_asset import save_user_asset
|
||||
from .user_asset_kind import user_asset_kind
|
||||
from .validate_audio_duration import validate_audio_duration, MAX_AUDIO_SECONDS
|
||||
from .validate_video_duration import validate_video_duration, MAX_VIDEO_SECONDS
|
||||
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 (
|
||||
TOKEN_TTL_MINUTES,
|
||||
consume_password_reset_token,
|
||||
create_password_reset_token,
|
||||
get_user_id_for_valid_token,
|
||||
)
|
||||
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 .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",
|
||||
"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",
|
||||
"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
|
||||
|
||||
@@ -2,7 +2,13 @@ from .connection import connect
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
|
||||
|
||||
def create_user_asset(user_id, filename, original_name=None, source="upload", scene_kind=None):
|
||||
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
|
||||
@@ -21,7 +27,7 @@ def create_user_asset(user_id, filename, original_name=None, source="upload", sc
|
||||
"INSERT INTO _user_assets (user_id, filename, original_name, source, scene_kind) VALUES (?, ?, ?, ?, ?)",
|
||||
(user_id, filename, original_name, source, scene_kind),
|
||||
)
|
||||
asset_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
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
|
||||
|
||||
@@ -5,16 +5,14 @@ from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
from .user_assets_dir import user_assets_dir
|
||||
|
||||
|
||||
def delete_user_asset(asset_id, user_id):
|
||||
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()
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .connection import connect
|
||||
|
||||
|
||||
def ensure_user_assets_schema():
|
||||
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),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from .connection import connect
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
|
||||
|
||||
def get_user_asset(asset_id):
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import re
|
||||
import struct
|
||||
|
||||
|
||||
def image_dimensions(content):
|
||||
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
|
||||
@@ -32,7 +31,7 @@ def image_dimensions(content):
|
||||
return None
|
||||
|
||||
|
||||
def _webp_dimensions(content):
|
||||
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))
|
||||
@@ -50,7 +49,7 @@ def _webp_dimensions(content):
|
||||
return None
|
||||
|
||||
|
||||
def _jpeg_dimensions(content):
|
||||
def _jpeg_dimensions(content: bytes) -> tuple[int, int] | None:
|
||||
i = 2
|
||||
n = len(content)
|
||||
while i + 9 < n:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from .connection import connect
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
|
||||
|
||||
def list_user_assets(user_id):
|
||||
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."""
|
||||
|
||||
@@ -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):
|
||||
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
|
||||
|
||||
@@ -5,7 +5,14 @@ from .create_user_asset import create_user_asset
|
||||
from .user_assets_dir import user_assets_dir
|
||||
|
||||
|
||||
def save_user_asset(user_id, content, ext, original_name=None, source="upload", scene_kind=None):
|
||||
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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -4,7 +4,7 @@ from .get_user_asset import get_user_asset
|
||||
from .user_asset_kind import user_asset_kind
|
||||
|
||||
|
||||
def update_user_asset_scene_kind(asset_id, user_id, scene_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
|
||||
|
||||
@@ -14,7 +14,7 @@ _AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"}
|
||||
_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".ogv"}
|
||||
|
||||
|
||||
def user_asset_kind(filename):
|
||||
def user_asset_kind(filename: str) -> str:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext in _IMAGE_EXTENSIONS:
|
||||
return "image"
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
from db.constants import USER_ASSETS_DIR
|
||||
|
||||
|
||||
def user_assets_dir(user_id):
|
||||
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."""
|
||||
|
||||
@@ -9,7 +9,7 @@ from mutagen import File as MutagenFile
|
||||
MAX_AUDIO_SECONDS = 180
|
||||
|
||||
|
||||
def validate_audio_duration(content, max_seconds=MAX_AUDIO_SECONDS):
|
||||
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
|
||||
|
||||
@@ -9,7 +9,9 @@ from mutagen import File as MutagenFile
|
||||
MAX_VIDEO_SECONDS = 300
|
||||
|
||||
|
||||
def validate_video_duration(content, filename="video.mp4", max_seconds=MAX_VIDEO_SECONDS):
|
||||
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/
|
||||
|
||||
@@ -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())
|
||||
|
||||
+19
-6
@@ -11,7 +11,9 @@ pour que `app.url_map` connaisse déjà toutes les routes au moment où ce
|
||||
module tente de résoudre request.endpoint — en pratique sans importance
|
||||
ici (la résolution se fait à la requête, pas à l'import), mais gardé pour
|
||||
rester cohérent avec l'ordre d'import du reste du moteur."""
|
||||
from flask import g, redirect, request, session, url_for, abort
|
||||
|
||||
from flask import abort, g, redirect, request, session, url_for
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
import auth
|
||||
from db.games.project_slug import split_slug
|
||||
@@ -22,8 +24,14 @@ from .flask_app import app
|
||||
# session valide. "static" (CSS/JS/images) doit rester public : la page
|
||||
# de connexion elle-même en a besoin pour s'afficher.
|
||||
_PUBLIC_ENDPOINTS = {
|
||||
"static", "login", "login_2fa", "register", "register_2fa", "logout",
|
||||
"forgot_password", "reset_password",
|
||||
"static",
|
||||
"login",
|
||||
"login_2fa",
|
||||
"register",
|
||||
"register_2fa",
|
||||
"logout",
|
||||
"forgot_password",
|
||||
"reset_password",
|
||||
}
|
||||
|
||||
# Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion
|
||||
@@ -32,13 +40,18 @@ _PUBLIC_ENDPOINTS = {
|
||||
# quand même pouvoir changer son mot de passe, son email, ou supprimer
|
||||
# son compte.
|
||||
_REACHABLE_WITHOUT_PROJECT = {
|
||||
"onboarding_new", "profile", "profile_update_name", "profile_update_email",
|
||||
"profile_regenerate_recovery_codes", "profile_change_password", "profile_delete",
|
||||
"onboarding_new",
|
||||
"profile",
|
||||
"profile_update_name",
|
||||
"profile_update_email",
|
||||
"profile_regenerate_recovery_codes",
|
||||
"profile_change_password",
|
||||
"profile_delete",
|
||||
}
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _require_login_and_enforce_project_isolation():
|
||||
def _require_login_and_enforce_project_isolation() -> Response | None:
|
||||
endpoint = request.endpoint
|
||||
if endpoint is None or endpoint in _PUBLIC_ENDPOINTS:
|
||||
return None
|
||||
|
||||
+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)
|
||||
+3
-2
@@ -19,10 +19,11 @@ _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)
|
||||
|
||||
+105
-70
@@ -20,88 +20,123 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
|
||||
vrai INSERT dans cette table.
|
||||
"""
|
||||
|
||||
from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
|
||||
from .slugify import slugify
|
||||
from .table_name_for import table_name_for
|
||||
from .game_dir import game_dir
|
||||
from .db_path import db_path
|
||||
from .connection import connect
|
||||
|
||||
from .games.list_games import list_games
|
||||
from .games.game_meta import game_meta
|
||||
from .games.get_game_type import get_game_type, DEFAULT_GAME_TYPE
|
||||
from .games.create_game import create_game
|
||||
from .games.update_game_name import update_game_name
|
||||
from .games.delete_game import delete_game
|
||||
from .games.move_game import move_game
|
||||
from .games.get_xapi_settings import get_xapi_settings
|
||||
from .games.set_xapi_settings import set_xapi_settings
|
||||
from .games.get_success_threshold import get_success_threshold
|
||||
from .games.set_success_threshold import set_success_threshold
|
||||
from .games.get_scorm_version import get_scorm_version
|
||||
from .games.set_scorm_version import set_scorm_version
|
||||
from .games.game_type_catalog import (
|
||||
ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE,
|
||||
get_onboarding_type, get_onboarding_type_raw, set_onboarding_type,
|
||||
is_restricted,
|
||||
)
|
||||
|
||||
from .definitions.list_definitions import list_definitions
|
||||
from .definitions.get_definition import get_definition
|
||||
from .constants import FIELD_TYPES, GLOBAL_VARIABLE_TYPES, PROJECTS_DIR, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
|
||||
from .custom_events.create_custom_event import create_custom_event
|
||||
from .custom_events.get_custom_event import get_custom_event
|
||||
from .custom_events.list_custom_events import list_custom_events
|
||||
from .custom_events.update_custom_event import update_custom_event
|
||||
from .db_path import db_path
|
||||
from .definitions.create_definition import create_definition
|
||||
from .definitions.rename_definition import rename_definition
|
||||
from .definitions.definitions_referencing import definitions_referencing
|
||||
from .definitions.add_field_to_definition import add_field_to_definition
|
||||
from .definitions.update_field import update_field
|
||||
from .definitions.delete_field import delete_field
|
||||
from .definitions.delete_definition import delete_definition
|
||||
|
||||
from .rows.list_rows import list_rows
|
||||
from .rows.relation_options import relation_options
|
||||
from .rows.insert_row import insert_row
|
||||
from .rows.get_row import get_row
|
||||
from .rows.update_row import update_row
|
||||
from .rows.update_row_field import update_row_field
|
||||
from .rows.delete_row import delete_row
|
||||
from .rows.rows_referencing import rows_referencing
|
||||
|
||||
from .global_vars.ensure_global_vars_schema import PLAYER_SHARED
|
||||
from .global_vars.list_global_variables import list_global_variables
|
||||
from .global_vars.list_global_variables_for_player import list_global_variables_for_player
|
||||
from .global_vars.get_global_variable import get_global_variable
|
||||
from .definitions.get_definition import get_definition
|
||||
from .definitions.list_definitions import list_definitions
|
||||
from .dialogue_lines import QUESTION_REWARD_TYPES, sanitize_dialogue_lines, sum_question_rewards
|
||||
from .game_dir import game_dir
|
||||
from .games.create_game import create_game
|
||||
from .games.delete_game import delete_game
|
||||
from .games.game_meta import game_meta
|
||||
from .games.game_type_catalog import (
|
||||
DEFAULT_ONBOARDING_TYPE,
|
||||
ONBOARDING_TYPES,
|
||||
get_onboarding_type,
|
||||
get_onboarding_type_raw,
|
||||
is_restricted,
|
||||
set_onboarding_type,
|
||||
)
|
||||
from .games.get_game_type import DEFAULT_GAME_TYPE, get_game_type
|
||||
from .games.get_scorm_version import get_scorm_version
|
||||
from .games.get_success_threshold import get_success_threshold
|
||||
from .games.get_xapi_settings import get_xapi_settings
|
||||
from .games.list_games import list_games
|
||||
from .games.move_game import move_game
|
||||
from .games.set_scorm_version import set_scorm_version
|
||||
from .games.set_success_threshold import set_success_threshold
|
||||
from .games.set_xapi_settings import set_xapi_settings
|
||||
from .games.update_game_name import update_game_name
|
||||
from .global_vars.create_global_variable import create_global_variable
|
||||
from .global_vars.update_global_variable_value import update_global_variable_value
|
||||
from .global_vars.update_global_variable import update_global_variable
|
||||
from .global_vars.delete_global_variable import delete_global_variable
|
||||
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
|
||||
|
||||
from .global_vars.ensure_global_vars_schema import PLAYER_SHARED
|
||||
from .global_vars.get_global_variable import get_global_variable
|
||||
from .global_vars.list_global_variables import list_global_variables
|
||||
from .global_vars.list_global_variables_for_player import list_global_variables_for_player
|
||||
from .global_vars.update_global_variable import update_global_variable
|
||||
from .global_vars.update_global_variable_value import update_global_variable_value
|
||||
from .json_for_script import json_for_script
|
||||
from .rows.delete_row import delete_row
|
||||
from .rows.get_row import get_row
|
||||
from .rows.insert_row import insert_row
|
||||
from .rows.list_rows import list_rows
|
||||
from .rows.update_row import update_row
|
||||
from .rows.update_row_field import update_row_field
|
||||
from .scoring.get_score import get_score
|
||||
from .scoring.set_score_value import set_score_value
|
||||
from .scoring.set_status import set_status
|
||||
|
||||
from .custom_events.list_custom_events import list_custom_events
|
||||
from .custom_events.get_custom_event import get_custom_event
|
||||
from .custom_events.create_custom_event import create_custom_event
|
||||
from .custom_events.update_custom_event import update_custom_event
|
||||
|
||||
from .dialogue_lines import sanitize_dialogue_lines, QUESTION_REWARD_TYPES, sum_question_rewards
|
||||
from .slugify import slugify
|
||||
from .table_name_for import table_name_for
|
||||
|
||||
__all__ = [
|
||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
||||
"SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status",
|
||||
"slugify", "table_name_for", "game_dir", "db_path", "connect",
|
||||
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
|
||||
"get_game_type", "DEFAULT_GAME_TYPE",
|
||||
"ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type",
|
||||
"get_onboarding_type_raw", "set_onboarding_type", "is_restricted",
|
||||
"list_definitions", "get_definition", "create_definition", "rename_definition",
|
||||
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
|
||||
"PROJECTS_DIR",
|
||||
"FIELD_TYPES",
|
||||
"GLOBAL_VARIABLE_TYPES",
|
||||
"PLAYER_SHARED",
|
||||
"SCORE_STATUS_CHOICES",
|
||||
"SCORE_STATUS_LABELS",
|
||||
"get_score",
|
||||
"set_score_value",
|
||||
"set_status",
|
||||
"slugify",
|
||||
"table_name_for",
|
||||
"json_for_script",
|
||||
"game_dir",
|
||||
"db_path",
|
||||
"connect",
|
||||
"list_games",
|
||||
"game_meta",
|
||||
"create_game",
|
||||
"update_game_name",
|
||||
"delete_game",
|
||||
"move_game",
|
||||
"get_game_type",
|
||||
"DEFAULT_GAME_TYPE",
|
||||
"get_xapi_settings",
|
||||
"set_xapi_settings",
|
||||
"get_success_threshold",
|
||||
"set_success_threshold",
|
||||
"get_scorm_version",
|
||||
"set_scorm_version",
|
||||
"ONBOARDING_TYPES",
|
||||
"DEFAULT_ONBOARDING_TYPE",
|
||||
"get_onboarding_type",
|
||||
"get_onboarding_type_raw",
|
||||
"set_onboarding_type",
|
||||
"is_restricted",
|
||||
"list_definitions",
|
||||
"get_definition",
|
||||
"create_definition",
|
||||
"definitions_referencing",
|
||||
"delete_definition",
|
||||
"list_rows", "relation_options", "insert_row", "get_row", "update_row",
|
||||
"update_row_field", "delete_row", "rows_referencing",
|
||||
"list_global_variables", "list_global_variables_for_player", "get_global_variable", "create_global_variable",
|
||||
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
||||
"list_rows",
|
||||
"insert_row",
|
||||
"get_row",
|
||||
"update_row",
|
||||
"update_row_field",
|
||||
"delete_row",
|
||||
"list_global_variables",
|
||||
"list_global_variables_for_player",
|
||||
"get_global_variable",
|
||||
"create_global_variable",
|
||||
"update_global_variable_value",
|
||||
"update_global_variable",
|
||||
"delete_global_variable",
|
||||
"delete_global_variable_by_id",
|
||||
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
||||
"sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards",
|
||||
"list_custom_events",
|
||||
"get_custom_event",
|
||||
"create_custom_event",
|
||||
"update_custom_event",
|
||||
"sanitize_dialogue_lines",
|
||||
"QUESTION_REWARD_TYPES",
|
||||
"sum_question_rewards",
|
||||
]
|
||||
|
||||
+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()
|
||||
|
||||
@@ -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"]))
|
||||
col = f"{fname}_id"
|
||||
columns_sql.append(
|
||||
f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(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)")
|
||||
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()
|
||||
+18
-9
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
_MAX_LINES_PER_COLUMN = 200
|
||||
_MAX_SPEAKER_LENGTH = 60
|
||||
|
||||
@@ -12,7 +14,7 @@ _MIN_CHOICES = 2
|
||||
_MAX_CHOICES = 4
|
||||
|
||||
|
||||
def _sanitize_dialogue_line(raw_line):
|
||||
def _sanitize_dialogue_line(raw_line: dict[str, Any]) -> dict[str, Any] | None:
|
||||
speaker = raw_line.get("speaker")
|
||||
text = raw_line.get("text")
|
||||
if not isinstance(speaker, str) or not speaker.strip():
|
||||
@@ -32,7 +34,7 @@ def _sanitize_dialogue_line(raw_line):
|
||||
return line
|
||||
|
||||
|
||||
def _sanitize_question_line(raw_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
|
||||
@@ -42,8 +44,11 @@ def _sanitize_question_line(raw_line):
|
||||
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_line.get("correct_index"))
|
||||
correct_index = int(raw_correct_index)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (0 <= correct_index < len(choices)):
|
||||
@@ -56,12 +61,16 @@ def _sanitize_question_line(raw_line):
|
||||
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,
|
||||
"type": "question",
|
||||
"text": text.strip(),
|
||||
"choices": choices,
|
||||
"correct_index": correct_index,
|
||||
"reward_type": reward_type,
|
||||
"reward_amount": reward_amount,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_line(raw_line):
|
||||
def _sanitize_line(raw_line: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(raw_line, dict):
|
||||
return None
|
||||
if raw_line.get("type") == "question":
|
||||
@@ -69,7 +78,7 @@ def _sanitize_line(raw_line):
|
||||
return _sanitize_dialogue_line(raw_line)
|
||||
|
||||
|
||||
def sanitize_dialogue_lines(raw_lines):
|
||||
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)
|
||||
@@ -86,14 +95,14 @@ def sanitize_dialogue_lines(raw_lines):
|
||||
return [line for line in cleaned if line]
|
||||
|
||||
|
||||
def sum_question_rewards(lines):
|
||||
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 []):
|
||||
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,7 +6,7 @@ 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).
|
||||
|
||||
|
||||
@@ -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é —
|
||||
|
||||
@@ -4,7 +4,7 @@ DEFAULT_SCORM_VERSION = "1.2"
|
||||
VALID_SCORM_VERSIONS = ("1.2", "2004")
|
||||
|
||||
|
||||
def get_scorm_version(slug):
|
||||
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/
|
||||
@@ -16,4 +16,4 @@ def get_scorm_version(slug):
|
||||
conn.close()
|
||||
if row is None or row["value"] not in VALID_SCORM_VERSIONS:
|
||||
return DEFAULT_SCORM_VERSION
|
||||
return row["value"]
|
||||
return str(row["value"])
|
||||
|
||||
@@ -3,16 +3,14 @@ from ..connection import connect
|
||||
DEFAULT_SUCCESS_THRESHOLD_PERCENT = 70
|
||||
|
||||
|
||||
def get_success_threshold(slug):
|
||||
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()
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def get_xapi_settings(slug):
|
||||
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)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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(owner_folder):
|
||||
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
|
||||
@@ -17,7 +18,7 @@ def list_games(owner_folder):
|
||||
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 = []
|
||||
games: list[dict[str, Any]] = []
|
||||
entry_path = os.path.join(PROJECTS_DIR, owner_folder)
|
||||
if not os.path.isdir(entry_path):
|
||||
return games
|
||||
@@ -28,7 +29,7 @@ def list_games(owner_folder):
|
||||
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()
|
||||
|
||||
@@ -5,7 +5,7 @@ from ..game_dir import game_dir
|
||||
from .project_slug import build_slug, split_slug
|
||||
|
||||
|
||||
def move_game(old_slug, new_owner_folder):
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ from ..connection import connect
|
||||
from .get_scorm_version import VALID_SCORM_VERSIONS
|
||||
|
||||
|
||||
def set_scorm_version(slug, version):
|
||||
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))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def set_success_threshold(slug, percent):
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def set_xapi_settings(slug, endpoint, login, password=None):
|
||||
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à
|
||||
@@ -10,15 +10,9 @@ def set_xapi_settings(slug, endpoint, login, password=None):
|
||||
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 "",)
|
||||
)
|
||||
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.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
|
||||
|
||||
@@ -2,7 +2,7 @@ from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema
|
||||
|
||||
|
||||
def delete_global_variable(slug, name):
|
||||
def delete_global_variable(slug: str, name: str) -> None:
|
||||
"""Supprime TOUTES les lignes de ce nom, pas seulement la ligne modèle
|
||||
PLAYER_SHARED — une variable per_player peut avoir une ligne par joueur
|
||||
ayant déjà écrit dessus (voir update_global_variable_value.py)."""
|
||||
|
||||
@@ -2,7 +2,7 @@ from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema
|
||||
|
||||
|
||||
def delete_global_variable_by_id(slug, var_id):
|
||||
def delete_global_variable_by_id(slug: str, var_id: int) -> None:
|
||||
"""Version par id de delete_global_variable — utilisée par la page de
|
||||
gestion des variables (game_variables.html), où chaque ligne connaît son
|
||||
id technique mais pas nécessairement un nom encore garanti unique côté
|
||||
|
||||
@@ -3,7 +3,7 @@ from ..connection import connect
|
||||
PLAYER_SHARED = "__shared__"
|
||||
|
||||
|
||||
def ensure_global_vars_schema(slug):
|
||||
def ensure_global_vars_schema(slug: str) -> None:
|
||||
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
|
||||
crée _global_variables si absente. Depuis l'état par joueur (Phase 1),
|
||||
une variable a une ligne "modèle" créée par le créateur du jeu
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
|
||||
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
|
||||
from .resolve_player_key import resolve_player_key
|
||||
|
||||
|
||||
def get_global_variable(slug, name, player_id=PLAYER_SHARED):
|
||||
def get_global_variable(slug: str, name: str, player_id: str = PLAYER_SHARED) -> dict[str, Any] | None:
|
||||
"""Par NOM (pas par id — c'est comme ça qu'une variable est référencée
|
||||
partout ailleurs, condition de visibilité, action "Modifier une
|
||||
variable") et par joueur (voir resolve_player_key.py) : une variable
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
|
||||
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
|
||||
|
||||
|
||||
def list_global_variables(slug):
|
||||
def list_global_variables(slug: str) -> list[dict[str, Any]]:
|
||||
"""Toujours les lignes MODÈLES (PLAYER_SHARED) — c'est la liste des
|
||||
variables telle que le créateur les a définies dans le tableau de
|
||||
bord, jamais influencée par le nombre de joueurs ayant déjà leur
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user