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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+6
View File
@@ -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
View File
@@ -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"
}
}
+8
View File
@@ -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
+10
View File
@@ -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/
+80
View File
@@ -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$
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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
+17 -8
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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
+2 -4
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
from .connection import connect
def ensure_users_schema():
def ensure_users_schema() -> None:
conn = connect()
conn.executescript(
"""
+2 -2
View File
@@ -1,8 +1,8 @@
from .connection import connect
def ensure_user_assets_schema():
""""Mes assets" (voir user_assets_dir.py) : une image appartient à un
def ensure_user_assets_schema() -> None:
""" "Mes assets" (voir user_assets_dir.py) : une image appartient à un
COMPTE, jamais à un projet — table dans la base de comptes partagée
(auth/connection.py), pas dans le game.db d'un jeu (db/connection.py),
pour rester utilisable d'un projet à l'autre du même compte."""
+3 -1
View File
@@ -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
+4 -2
View File
@@ -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()
+4 -2
View File
@@ -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
+5 -6
View File
@@ -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:
@@ -63,9 +62,9 @@ def _jpeg_dimensions(content):
continue
if marker == 0xD9:
break
seg_len = struct.unpack(">H", content[i + 2:i + 4])[0]
seg_len = struct.unpack(">H", content[i + 2 : i + 4])[0]
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
height, width = struct.unpack(">HH", content[i + 5:i + 9])
height, width = struct.unpack(">HH", content[i + 5 : i + 9])
return width, height
i += 2 + seg_len
return None
+3 -3
View File
@@ -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)
+4 -2
View File
@@ -1,9 +1,11 @@
from typing import Any
from .connection import connect
from .ensure_user_assets_schema import ensure_user_assets_schema
def list_user_assets(user_id):
""""Mes assets" d'un compte, plus récent en premier — voir
def list_user_assets(user_id: int) -> list[dict[str, Any]]:
""" "Mes assets" d'un compte, plus récent en premier — voir
templates/scene_edit.html, bloc "Mes assets" du panneau d'ajout
d'élément."""
ensure_user_assets_schema()
+5 -5
View File
@@ -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 = ?",
+3 -2
View File
@@ -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
View File
@@ -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."""
+1 -1
View File
@@ -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
+5 -5
View File
@@ -7,11 +7,11 @@ from .connection import connect
_CODE_COUNT = 10
def _format_code(raw):
return "-".join(raw[i:i + 4] for i in range(0, len(raw), 4))
def _format_code(raw: str) -> str:
return "-".join(raw[i : i + 4] for i in range(0, len(raw), 4))
def generate_recovery_codes(user_id):
def generate_recovery_codes(user_id: int) -> list[str]:
"""(Re)génère les codes de récupération 2FA d'un utilisateur : un
nouvel appel invalide tout jeu de codes précédent (un seul jeu valide
à la fois, pour ne jamais avoir à deviner lesquels tiennent encore).
@@ -21,7 +21,7 @@ def generate_recovery_codes(user_id):
directement réutilisables."""
conn = connect()
conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,))
codes = []
codes: list[str] = []
for _ in range(_CODE_COUNT):
code = _format_code(secrets.token_hex(6))
codes.append(code)
@@ -34,7 +34,7 @@ def generate_recovery_codes(user_id):
return codes
def verify_recovery_code(user_id, code):
def verify_recovery_code(user_id: int, code: str | None) -> bool:
"""Un code n'est utilisable qu'UNE SEULE FOIS (used_at) : consommé dès
qu'il sert à une connexion réussie, pour qu'un code intercepté une
fois (capture d'écran, historique du navigateur...) ne redonne pas un
+1 -1
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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 -1
View File
@@ -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
+4 -4
View File
@@ -5,11 +5,11 @@ import qrcode
import qrcode.image.svg
def totp_provisioning_uri(secret, email):
return pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine")
def totp_provisioning_uri(secret: str, email: str) -> str:
return str(pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine"))
def totp_qrcode_svg(secret, email):
def totp_qrcode_svg(secret: str, email: str) -> str:
"""SVG (pas PNG) : la variante "image factory" par défaut de qrcode a
besoin de Pillow pour produire un PNG — SvgPathImage, elle, est du pur
Python, sans dépendance supplémentaire à installer juste pour un QR
@@ -30,4 +30,4 @@ def totp_qrcode_svg(secret, email):
buf = io.BytesIO()
img.save(buf)
svg = buf.getvalue().decode("utf-8")
return svg[svg.index("<svg"):]
return svg[svg.index("<svg") :]
+1 -1
View File
@@ -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 -1
View File
@@ -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()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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."""
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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/
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"):
+11
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+3 -3
View File
@@ -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 —
+3 -1
View File
@@ -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)
+3 -1
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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")
-44
View File
@@ -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()
+11 -6
View File
@@ -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"]
+3 -1
View File
@@ -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."""
+3 -1
View File
@@ -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,))
-29
View File
@@ -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 -1
View File
@@ -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 à
+3 -1
View File
@@ -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()
+3 -1
View File
@@ -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()
-8
View File
@@ -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()
-49
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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
+5 -2
View File
@@ -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).
+11 -9
View File
@@ -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"])
+1 -1
View File
@@ -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é —
+2 -2
View File
@@ -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"])
+2 -4
View File
@@ -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 -1
View File
@@ -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)
+5 -4
View File
@@ -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()
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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)
+4 -10
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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/
+6 -4
View File
@@ -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
+1 -1
View File
@@ -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é
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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
+4 -2
View File
@@ -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
@@ -1,9 +1,11 @@
from typing import Any
from .ensure_global_vars_schema import PLAYER_SHARED
from .list_global_variables import list_global_variables
from .get_global_variable import get_global_variable
from .list_global_variables import list_global_variables
def list_global_variables_for_player(slug, player_id=PLAYER_SHARED):
def list_global_variables_for_player(slug: str, player_id: str = PLAYER_SHARED) -> list[dict[str, Any]]:
"""Valeur EFFECTIVE de chaque variable pour ce joueur (voir
get_global_variable.py : la ligne propre à ce joueur si elle existe
déjà, sinon la valeur par défaut de la ligne modèle) — à la différence
@@ -11,7 +13,7 @@ def list_global_variables_for_player(slug, player_id=PLAYER_SHARED):
tableau de bord), c'est cette version qu'il faut exposer au runtime
jouable (full_game_payload.py), pour qu'une condition évaluée côté
client reflète bien l'état RÉEL de CE joueur."""
result = []
result: list[dict[str, Any]] = []
for template in list_global_variables(slug):
value = get_global_variable(slug, template["name"], player_id)
result.append(value if value else template)

Some files were not shown because too many files have changed in this diff Show More