Ajoute les blocs de logique : organise le graphe de flow en sous-graphes nommés

Le graphe de logique d'une scène s'affichait jusqu'ici sur un seul
canevas plat (toutes les scènes accumulant leurs nœuds sur la même
grille), ce qui ne tient pas à l'échelle dès qu'une scène évolue au fil
de l'avancée du joueur et accumule des centaines/milliers de nœuds.

Ajoute les "Blocs de logique" : un bloc regroupe un sous-ensemble de
nœuds/arêtes d'un écran sous un nom et une description (comme une
fonction). L'onglet "Logique de la scène" devient une liste de blocs
(nom, description tronquée à 3 phrases, éléments concernés, nombre de
nœuds, bouton "Ouvrir"). Ouvrir un bloc affiche SON graphe dans une
modale plein écran, redimensionnable et déplaçable (patron déjà mûr
dans game_dashboard.html, porté tel quel : makeFloatPanelDraggable/
Resizable/Fullscreenable).

Décision d'architecture : un bloc est un automate FERMÉ — impossible de
relier un nœud d'un bloc à un nœud d'un autre bloc (rejeté côté serveur
dans flow_edge_add.py). Toute communication entre deux blocs passe par
le système d'événements personnalisés déjà en place
(declencher_evenement / trigger_event="evenement").

Détails techniques :
- Nouvelle colonne _flow_nodes.block_id (nullable, sans FK — même
  rationale que trigger_element_id/target_element_id, voir
  screens/elements/delete_element.py) et nouvelle table _flow_blocks
  (screens/flow/ensure_flow_schema.py,
  screens/flow/blocks/ensure_flow_blocks_schema.py).
- Migration douce et automatique : les nœuds posés avant l'existence
  des blocs (block_id NULL) sont rattachés, à la première ouverture de
  l'onglet, à un "Bloc principal" auto-créé (screens/flow/blocks/
  list_flow_blocks.py) — aucun script de migration séparé, aucune
  donnée perdue.
- Suppression d'un bloc = cascade complète (bloc + tous ses nœuds/
  arêtes), patron identique à screens/custom_events/delete_custom_event.py
  mais scopé à un seul bloc plutôt que game-wide.
- Routes CRUD sous routes/flow_blocks/, montées comme routes/custom_events/.
- templates/screen_edit.html : FLOW (global unique) renommé en ALL_FLOW
  (toutes les données de l'écran) ; un seul bloc ouvert à la fois
  (modale unique, à la Unity) — currentBlockNodes()/currentBlockEdges()
  filtrent ALL_FLOW par CURRENT_BLOCK_ID à chaque rendu, sans tenir de
  seconde copie à synchroniser manuellement.

Vérifié : 215 tests passent (7 nouveaux dans tests/test_flow_blocks.py,
dont un qui verrouille l'ordre d'appel list_flow_blocks()/
list_flow_nodes() dans screen_edit.py — la migration douce doit tourner
AVANT le chargement des nœuds, sinon le compte de nœuds affiché juste
après une migration est périmé), syntaxe JS validée (script de
screen_edit.html rendu via le client de test puis node --check).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-30 11:30:49 +02:00
co-authored by Claude Sonnet 5
parent a7a315cce7
commit 1b7706b357
24 changed files with 638 additions and 22 deletions
View File
+20
View File
@@ -0,0 +1,20 @@
def blocks_view(flow_blocks, flow_nodes, elements_labels):
"""Enrichit chaque bloc (nom/description bruts) avec son nombre de
nœuds et les éléments qu'il concerne (trigger_element_id des
déclencheurs + target_element_id des actions, dédoublonnés) — calculé
en Python à partir de ce que routes/screens/screen_edit.py a déjà
chargé, sans requête SQL supplémentaire."""
nodes_by_block = {}
for n in flow_nodes:
nodes_by_block.setdefault(n.get("block_id"), []).append(n)
view = []
for b in flow_blocks:
block_nodes = nodes_by_block.get(b["id"], [])
element_ids = {n["trigger_element_id"] for n in block_nodes if n.get("trigger_element_id")}
element_ids |= {n["target_element_id"] for n in block_nodes if n.get("target_element_id")}
view.append({
**b,
"node_count": len(block_nodes),
"element_labels": [elements_labels[eid] for eid in element_ids if eid in elements_labels],
})
return view
+19
View File
@@ -0,0 +1,19 @@
import db
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
def create_flow_block(slug, screen_id, name, description=""):
name = (name or "").strip()
if not name:
return None
ensure_flow_blocks_schema(slug)
conn = db.connect(slug)
conn.execute(
"INSERT INTO _flow_blocks (screen_id, name, description) VALUES (?, ?, ?)",
(screen_id, name, (description or "").strip()),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
+26
View File
@@ -0,0 +1,26 @@
import db
from ..ensure_flow_schema import ensure_flow_schema
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
def delete_flow_block(slug, block_id):
"""Supprime le bloc ET tout ce qu'il contient (nœuds + arêtes) — décision
explicite de l'utilisateur, un bloc de logique se comporte comme une
fonction qu'on supprime avec son corps. Patron identique à
screens/custom_events/delete_custom_event.py, mais scopé à un seul bloc
(pas game-wide) : les AUTRES blocs du même écran ne sont pas touchés."""
ensure_flow_schema(slug)
ensure_flow_blocks_schema(slug)
conn = db.connect(slug)
conn.execute(
"""DELETE FROM _flow_edges WHERE from_node_id IN
(SELECT id FROM _flow_nodes WHERE block_id = ?)
OR to_node_id IN
(SELECT id FROM _flow_nodes WHERE block_id = ?)""",
(block_id, block_id),
)
conn.execute("DELETE FROM _flow_nodes WHERE block_id = ?", (block_id,))
conn.execute("DELETE FROM _flow_blocks WHERE id = ?", (block_id,))
conn.commit()
conn.close()
@@ -0,0 +1,24 @@
import db
def ensure_flow_blocks_schema(slug):
"""Un bloc de logique regroupe les nœuds d'UN écran (scène ou modèle)
en sous-graphe nommé/décrit, pour rester lisible même quand un écran
accumule des milliers de nœuds — voir _flow_nodes.block_id
(screens/flow/ensure_flow_schema.py). Contrairement à un événement
personnalisé (game-wide, voir db/custom_events/), un bloc n'a de sens
que pour SON écran : "name" n'est donc PAS unique globalement."""
conn = db.connect(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _flow_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+13
View File
@@ -0,0 +1,13 @@
import db
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
def get_flow_block(slug, block_id):
if not block_id:
return None
ensure_flow_blocks_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _flow_blocks WHERE id = ?", (block_id,)).fetchone()
conn.close()
return dict(row) if row else None
+45
View File
@@ -0,0 +1,45 @@
import db
from ..ensure_flow_schema import ensure_flow_schema
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
DEFAULT_BLOCK_NAME = "Bloc principal"
def list_flow_blocks(slug, screen_id):
"""Migration douce : un écran qui a des nœuds posés AVANT l'existence
des blocs (block_id NULL) se voit attribuer, à la première consultation
de l'onglet Blocs, un bloc "Bloc principal" auto-créé qui les regroupe —
aucun script de migration séparé à faire tourner, aucune donnée perdue.
Idempotent : si "Bloc principal" existe déjà pour cet écran, on le
réutilise plutôt que d'en recréer un second."""
ensure_flow_schema(slug)
ensure_flow_blocks_schema(slug)
conn = db.connect(slug)
orphan_count = conn.execute(
"SELECT COUNT(*) AS n FROM _flow_nodes WHERE screen_id = ? AND block_id IS NULL",
(screen_id,),
).fetchone()["n"]
if orphan_count:
default_block = conn.execute(
"SELECT id FROM _flow_blocks WHERE screen_id = ? AND name = ?",
(screen_id, DEFAULT_BLOCK_NAME),
).fetchone()
if default_block:
default_block_id = default_block["id"]
else:
conn.execute(
"INSERT INTO _flow_blocks (screen_id, name, description) VALUES (?, ?, ?)",
(screen_id, DEFAULT_BLOCK_NAME, ""),
)
default_block_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.execute(
"UPDATE _flow_nodes SET block_id = ? WHERE screen_id = ? AND block_id IS NULL",
(default_block_id, screen_id),
)
conn.commit()
rows = conn.execute(
"SELECT * FROM _flow_blocks WHERE screen_id = ? ORDER BY id", (screen_id,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
+17
View File
@@ -0,0 +1,17 @@
import db
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
def update_flow_block(slug, block_id, name, description=""):
name = (name or "").strip()
if not name:
return
ensure_flow_blocks_schema(slug)
conn = db.connect(slug)
conn.execute(
"UPDATE _flow_blocks SET name = ?, description = ? WHERE id = ?",
(name, (description or "").strip(), block_id),
)
conn.commit()
conn.close()