Bibliothèque de sprites animaux CraftPix (1/3) : catalogue, galerie, accès admin
Build and deploy / test-python (push) Failing after 12s
Build and deploy / test-js (push) Successful in 6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Premier commit d'une fonctionnalité découpée en plusieurs lots (voir le
plan "Bibliothèque de sprites animaux CraftPix") : intègre 14 familles
d'animaux (15 variantes de couleur chacune) comme personnages Forge
sélectionnables, à côté des 6 Kenney existants — réservé au rôle admin,
licence CraftPix oblige (interdiction contractuelle de rendre ces sprites
utilisables par un compte "user" via l'application).

- screens/labels/animal_sprite_library.py (nouveau) : charge un manifest
  JSON généré une fois (voir scripts/generate_animal_sprite_manifest.py,
  commit suivant) et construit ADMIN_SPRITE_LIBRARY, dans le même format
  que l'existant PUBLIC_SPRITE_LIBRARY (screens/labels/sprite_library.py,
  ex-SPRITE_LIBRARY, renommé pour distinguer les deux). screens.SPRITE_LIBRARY
  reste le catalogue FUSIONNÉ (utilisé par resolve_personnage_animations
  pour la résolution runtime, sans filtrage par rôle — voir le constat
  d'exploration : le payload de jeu et /jouer/<slug> ne vérifient déjà
  aucun rôle nulle part).
- screens/labels/sprite_gallery.py (nouveau) : sprite_gallery_families()
  groupe la galerie par famille — un animal n'apparaît qu'une fois (sa
  variante "de base"), ses 15 couleurs se choisissent depuis le panneau
  de propriétés (render_variant_gallery, templates/screen_edit.html),
  répondant à la suggestion de l'utilisateur plutôt que d'encombrer la
  galerie d'ajout de 210 tuiles quasi identiques.
- routes/screens/screen_edit.py, routes/scenes/scene_edit_view.py :
  la galerie passée au template est filtrée par rôle
  (PUBLIC_SPRITE_LIBRARY pour un compte "user", SPRITE_LIBRARY complet
  pour un admin) — même idiome que core/auth_guard.py.
- core/sprite_gate.py (nouveau) + 4 routes d'écriture (element_add,
  element_set_personnage_data, scene_object_add, scene_object_personnage_data) :
  ferme la brèche d'un POST direct qui contournerait la galerie filtrée
  (403 si un compte non-admin tente d'assigner un personnage animal).
- tests/conftest.py : nouvelles fixtures user_client/user_game (compte
  "user" non-admin avec un projet assigné) pour tester le filtrage par
  rôle de bout en bout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-31 21:49:05 +02:00
co-authored by Claude Sonnet 5
parent 43abfc9b93
commit 449c36fd5d
18 changed files with 430 additions and 25 deletions
+16
View File
@@ -0,0 +1,16 @@
"""Garde d'accès aux sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY)
— licence CraftPix, réservés au rôle admin (voir le plan "Bibliothèque de
sprites animaux CraftPix"). La galerie de l'éditeur (routes/screens/
screen_edit.py, routes/scenes/scene_edit_view.py) ne les propose déjà pas
à un compte "user", mais un POST direct vers une route d'écriture
(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):
if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin":
abort(403)
+2
View File
@@ -3,6 +3,7 @@ from flask import request, redirect, url_for
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_character
@app.route("/game/<slug>/screens/<int:screen_id>/elements/add", methods=["POST"])
@@ -31,5 +32,6 @@ def element_add(slug, screen_id):
# réellement cliqué.
forge_character = request.form.get("forge_character")
if forge_character and forge_character in screens.SPRITE_LIBRARY:
forbid_admin_only_character(forge_character)
screens.set_personnage_data(slug, new_id, {"source": "forge", "forge_character": forge_character})
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
@@ -3,6 +3,7 @@ from flask import request, jsonify
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_character
@app.route("/game/<slug>/elements/<int:element_id>/personnage", methods=["POST"])
@@ -16,6 +17,8 @@ def element_set_personnage_data(slug, element_id):
el = screens.get_element(slug, element_id)
if not el:
return jsonify({"error": "élément introuvable"}), 404
if body.get("source") == "forge" and body.get("forge_character"):
forbid_admin_only_character(body["forge_character"])
screens.set_personnage_data(slug, element_id, body)
el = screens.get_element(slug, element_id)
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(el)})
+9 -2
View File
@@ -1,6 +1,6 @@
import json
from flask import render_template, request, abort
from flask import render_template, request, abort, g
import db
import screens
@@ -46,11 +46,18 @@ def render_scene_edit(slug, screen_id, game):
screens_map = {s["id"]: s["name"] for s in all_screens}
blocks_view = screens.blocks_view(flow_blocks, flow_nodes, object_labels)
# Sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY) : réservés au
# rôle admin (licence CraftPix, voir core/sprite_gate.py et le plan
# "Bibliothèque de sprites animaux CraftPix").
sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY
sprite_library_families = screens.sprite_gallery_families(sprite_library)
return render_template(
"scene_edit.html",
game=game, screen=screen, all_screens=all_screens,
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
sprite_library=screens.SPRITE_LIBRARY, sprite_library_json=json.dumps(screens.SPRITE_LIBRARY),
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
sprite_library_json=json.dumps(sprite_library),
element_animations_map_json=json.dumps(element_animations_map),
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
+3
View File
@@ -3,6 +3,7 @@ from flask import request, redirect, url_for
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_character
@app.route("/game/<slug>/screens/<int:screen_id>/scene-objects/add", methods=["POST"])
@@ -12,6 +13,8 @@ def scene_object_add(slug, screen_id):
add_scene_object.py)."""
kind = request.form.get("kind", "personnage")
forge_character = request.form.get("forge_character")
if forge_character:
forbid_admin_only_character(forge_character)
new_id = screens.add_scene_object(slug, screen_id, kind=kind, forge_character=forge_character)
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
@@ -3,6 +3,7 @@ from flask import request, jsonify
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_character
@app.route("/game/<slug>/scene-objects/<int:object_id>/personnage", methods=["POST"])
@@ -17,6 +18,8 @@ def scene_object_personnage_data(slug, object_id):
obj = screens.get_scene_object(slug, object_id)
if not obj:
return jsonify({"error": "objet introuvable"}), 404
if body.get("source") == "forge" and body.get("forge_character"):
forbid_admin_only_character(body["forge_character"])
screens.set_scene_object_personnage_data(slug, object_id, body)
obj = screens.get_scene_object(slug, object_id)
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(obj)})
+10 -3
View File
@@ -1,6 +1,6 @@
import json
from flask import render_template, request, abort
from flask import render_template, request, abort, g
import db
import screens
@@ -78,6 +78,13 @@ def screen_edit(slug, screen_id):
blocks_view = screens.blocks_view(flow_blocks, flow_nodes, elements_labels)
# Sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY) : réservés au
# rôle admin (licence CraftPix, voir core/sprite_gate.py et le plan
# "Bibliothèque de sprites animaux CraftPix") — un compte "user" ne
# doit même pas les voir dans la galerie.
sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY
sprite_library_families = screens.sprite_gallery_families(sprite_library)
return render_template(
"screen_edit.html",
game=game, screen=screen, elements=elements, top_level_elements=top_level_elements,
@@ -93,8 +100,8 @@ def screen_edit(slug, screen_id):
element_surbrillance_values=screens.ELEMENT_SURBRILLANCE_VALUES,
element_desactive_values=screens.ELEMENT_DESACTIVE_VALUES,
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
sprite_library=screens.SPRITE_LIBRARY,
sprite_library_json=json.dumps(screens.SPRITE_LIBRARY),
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
sprite_library_json=json.dumps(sprite_library),
personnage_data=personnage_data,
element_animations_map_json=json.dumps(element_animations_map),
data_operations=screens.DATA_OPERATIONS,
+13 -1
View File
@@ -55,7 +55,19 @@ from .labels.element_action_properties import (
)
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
from .labels.sprite_library import SPRITE_LIBRARY
from .labels.sprite_library import PUBLIC_SPRITE_LIBRARY
from .labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
# SPRITE_LIBRARY (fusionné) : utilisé par resolve_personnage_animations
# pour la résolution RUNTIME (jeu jouable, tous rôles confondus — voir le
# plan "Bibliothèque de sprites animaux CraftPix" : aucun filtrage par
# rôle au runtime, seulement à la galerie/l'écriture). Les routes qui
# alimentent la galerie de l'éditeur doivent utiliser PUBLIC_SPRITE_LIBRARY
# ou ADMIN_SPRITE_LIBRARY selon le rôle, jamais SPRITE_LIBRARY directement.
SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY}
ADMIN_ONLY_CHARACTER_SLUGS = set(ADMIN_SPRITE_LIBRARY)
from .labels.sprite_gallery import sprite_gallery_families
from .rendering.list_used_forge_characters import list_used_forge_characters
from .flow.constants import (
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID,
+63
View File
@@ -0,0 +1,63 @@
import json
import os
# Bibliothèque de sprites animaux CraftPix — RÉSERVÉE au rôle admin (voir
# routes/screens/screen_edit.py, routes/scenes/scene_edit_view.py, et les
# routes d'écriture screens/scene-objects "personnage"/"add"). Licence
# CraftPix : interdiction de rendre ces sprites utilisables par un compte
# "user" via l'application (voir le plan "Bibliothèque de sprites animaux
# CraftPix"). Générée une fois par scripts/generate_animal_sprite_manifest.py
# depuis assets/characters/animal_sprite/ (pack source, non versionné) vers
# static/characters/animals/<animal>/<NN>/<pose><i>.png, avec un manifest
# JSON décrivant le compte RÉEL de frames par pose/variante (variable selon
# les packs — pas de table figée comme _SEQUENCE_ANIMATIONS pour Kenney).
_MANIFEST_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"static", "characters", "animals", "manifest.json",
)
def _load_manifest():
try:
with open(_MANIFEST_PATH, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
# Poste de dev sans les assets animaux copiés (script pas encore
# lancé), ou paquet exporté pour un jeu qui n'utilise aucun
# personnage animal (voir publish/build_package.py, copie
# sélective) : aucun personnage animal disponible, pas une erreur.
return {}
def _slug(animal, nn):
return f"animal-{animal}-{nn}"
def _label(family_label, nn):
n = int(nn)
return f"{family_label} (variante {n})" if n > 1 else f"{family_label} (de base)"
def _animations_for(animal, nn, poses):
return {
pose: [f"/static/characters/animals/{animal}/{nn}/{pose}{i}.png" for i in range(count)]
for pose, count in poses.items()
}
def _build_library():
manifest = _load_manifest()
library = {}
for animal, pack in manifest.items():
family_label = pack["label"]
for nn, poses in pack["variants"].items():
library[_slug(animal, nn)] = {
"label": _label(family_label, nn),
"animations": _animations_for(animal, nn, poses),
"family": animal,
"family_label": family_label,
}
return library
ADMIN_SPRITE_LIBRARY = _build_library()
+21
View File
@@ -0,0 +1,21 @@
def sprite_gallery_families(sprite_library):
"""Un tile par FAMILLE pour la galerie d'ajout/de changement de
personnage — les personnages Kenney (pas de clé "family", voir
screens/labels/sprite_library.py) sont chacun leur propre famille ;
les animaux CraftPix (screens/labels/animal_sprite_library.py) sont
représentés par leur variante "de base" (dict Python : ordre
d'insertion garanti, "01" toujours vu en premier — voir
_build_library()). Les autres variantes se choisissent depuis le
panneau de propriétés (voir render_variant_gallery, templates/
screen_edit.html) plutôt que d'encombrer cette galerie de 15 tiles
quasi identiques par animal."""
seen_families = set()
families = []
for slug, character in sprite_library.items():
family = character.get("family")
if family:
if family in seen_families:
continue
seen_families.add(family)
families.append((slug, character))
return families
+6 -1
View File
@@ -48,7 +48,12 @@ def _animations_for(slug):
return animations
SPRITE_LIBRARY = {
# Bibliothèque publique — visible par tous les comptes (CC0, voir
# LICENSE-kenney.txt). Distincte de screens.ADMIN_SPRITE_LIBRARY (sprites
# animaux CraftPix, réservés au rôle admin — voir
# screens/labels/animal_sprite_library.py et le plan "Bibliothèque de
# sprites animaux CraftPix").
PUBLIC_SPRITE_LIBRARY = {
slug: {"label": label, "animations": _animations_for(slug)}
for slug, label in CHARACTERS.items()
}
+10 -2
View File
@@ -1,6 +1,14 @@
import json
from ..labels.sprite_library import SPRITE_LIBRARY
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
from ..labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
# Résolution RUNTIME (jeu jouable) : le catalogue COMPLET, quel que soit le
# rôle du créateur — voir le plan "Bibliothèque de sprites animaux
# CraftPix" (aucun filtrage par rôle au runtime, seulement à la galerie de
# l'éditeur/l'écriture, voir routes/screens/screen_edit.py et
# routes/elements/element_add.py).
_SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY}
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
@@ -26,7 +34,7 @@ def resolve_personnage_animations(el):
data = resolve_personnage_data(el)
if data.get("source") == "custom":
return data.get("animations") or {}
character = SPRITE_LIBRARY.get(data.get("forge_character"))
character = _SPRITE_LIBRARY.get(data.get("forge_character"))
return character["animations"] if character else {}
+3 -2
View File
@@ -22,8 +22,9 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None):
if kind == "personnage":
data = dict(_DEFAULT_PERSONNAGE_DATA)
if forge_character:
from ..labels.sprite_library import SPRITE_LIBRARY
if forge_character in SPRITE_LIBRARY:
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
from ..labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
if forge_character in PUBLIC_SPRITE_LIBRARY or forge_character in ADMIN_SPRITE_LIBRARY:
data["forge_character"] = forge_character
attributes["_personnage_data"] = json.dumps(data)
conn = db.connect(slug)
+19 -7
View File
@@ -67,14 +67,14 @@
<details class="propGroup" open>
<summary>🎭 Personnages</summary>
<div class="propGroupBody">
<div class="iconGallery">
{% for slug_c, character in sprite_library.items() %}
<div class="iconGallery personnageGallery">
{% for slug_c, character in sprite_library_families %}
<button type="button" class="iconGalleryTile" title="{{ character.label }}" onclick="addSceneObject('personnage', '{{ slug_c }}')">
<img src="/static/characters/{{ slug_c }}/idle.png" alt="{{ character.label }}" class="personnageThumb">
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
</button>
{% endfor %}
</div>
<p class="help">Personnage Kenney prêt à l'emploi (licence libre CC0), avec toutes ses animations.</p>
<p class="help">Personnage Kenney (licence libre CC0) ou animal Forge, avec toutes ses animations — les variantes de couleur d'un animal se choisissent depuis le panneau de propriétés.</p>
</div>
</details>
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('decor')">🖼️ + Ajouter un décor</button>
@@ -629,14 +629,26 @@
<summary>🎭 Personnage</summary>
<div class="propGroupBody" id="personnagePanelBody" data-element-id="{{ obj.id }}">
<p class="help">Personnage Forge — clique un autre pour en changer.</p>
<div class="iconGallery" id="personnageGallerySwap">
{% for slug_c, character in sprite_library.items() %}
<div class="iconGallery personnageGallery" id="personnageGallerySwap">
{% for slug_c, character in sprite_library_families %}
<button type="button" class="iconGalleryTile {{ 'selected' if slug_c == personnage_data.forge_character else '' }}"
title="{{ character.label }}" onclick="swapSceneObjectCharacter('{{ slug_c }}')">
<img src="/static/characters/{{ slug_c }}/idle.png" alt="{{ character.label }}" class="personnageThumb">
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
</button>
{% endfor %}
</div>
{% set current_character = sprite_library.get(personnage_data.forge_character) %}
{% if current_character and current_character.get('family') %}
<p class="help">Variantes de {{ current_character.family_label }} :</p>
<div class="iconGallery personnageGallery" id="personnageGalleryVariants">
{% for slug_c, character in sprite_library.items() if character.get('family') == current_character.family %}
<button type="button" class="iconGalleryTile {{ 'selected' if slug_c == personnage_data.forge_character else '' }}"
title="{{ character.label }}" onclick="swapSceneObjectCharacter('{{ slug_c }}')">
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
</button>
{% endfor %}
</div>
{% endif %}
</div>
</details>
{% endif %}
+35 -4
View File
@@ -43,14 +43,40 @@
<!-- Phase 8 — galerie visuelle de personnages Forge (VRAIES miniatures,
pas un emoji/icône), réutilisée deux fois : "add" pour poser un
nouveau personnage (panneau gauche), "swap" pour changer le
personnage Forge d'un élément déjà posé (panneau droit, propriétés). -->
personnage Forge d'un élément déjà posé (panneau droit, propriétés).
Un tile par FAMILLE (sprite_library_families, voir
screens/labels/sprite_gallery.py) — un animal CraftPix (screens/
labels/animal_sprite_library.py) n'apparaît ici qu'une fois, dans sa
variante "de base" ; ses 15 variantes se choisissent depuis
render_variant_gallery ci-dessous, dans le panneau de propriétés.
Miniature = 1ère frame de l'animation "idle" du personnage (jamais un
chemin en dur : le rangement sur disque diffère entre Kenney et les
animaux, voir screens/labels/animal_sprite_library.py). -->
{% macro render_personnage_gallery(mode, gallery_id, selected_forge_character=None) %}
<div class="iconGallery" id="{{ gallery_id }}">
{% for char_slug, character in sprite_library.items() %}
<div class="iconGallery personnageGallery" id="{{ gallery_id }}">
{% for char_slug, character in sprite_library_families %}
<button type="button" class="iconGalleryTile {{ 'selected' if char_slug == selected_forge_character else '' }}"
title="{{ character.label }}"
onclick="{{ 'addPersonnageElement' if mode == 'add' else 'swapPersonnageCharacter' }}('{{ char_slug }}')">
<img src="/static/characters/{{ char_slug }}/idle.png" alt="{{ character.label }}" class="personnageThumb">
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
</button>
{% endfor %}
</div>
{% endmacro %}
<!-- Variantes de couleur/skin d'UNE famille d'animal (les 15 personnages
CraftPix qui partagent le même "family", voir screens/labels/
animal_sprite_library.py) — affichée uniquement dans le panneau de
propriétés, sous la galerie groupée ci-dessus, quand le personnage
sélectionné appartient à une famille à variantes (jamais pour un
Kenney, qui n'a pas de "family"). -->
{% macro render_variant_gallery(family, gallery_id, selected_forge_character=None) %}
<div class="iconGallery personnageGallery" id="{{ gallery_id }}">
{% for char_slug, character in sprite_library.items() if character.get('family') == family %}
<button type="button" class="iconGalleryTile {{ 'selected' if char_slug == selected_forge_character else '' }}"
title="{{ character.label }}"
onclick="swapPersonnageCharacter('{{ char_slug }}')">
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
</button>
{% endfor %}
</div>
@@ -902,6 +928,11 @@
<div class="propGroupBody" id="personnagePanelBody" data-element-id="{{ el.id }}">
<p class="help">Personnage Forge — clique un autre pour en changer.</p>
{{ render_personnage_gallery('swap', 'personnageGallerySwap', personnage_data.forge_character) }}
{% set current_character = sprite_library.get(personnage_data.forge_character) %}
{% if current_character and current_character.get('family') %}
<p class="help">Variantes de {{ current_character.family_label }} :</p>
{{ render_variant_gallery(current_character.family, 'personnageGalleryVariants', personnage_data.forge_character) }}
{% endif %}
</div>
</details>
{% endif %}
+40
View File
@@ -74,6 +74,46 @@ def client():
yield c
_TEST_USER_COUNTER = [0]
@pytest.fixture
def user_client():
"""Un compte "user" (jamais le premier de la session de tests, donc
jamais admin — voir auth/create_user.py), connecté mais SANS projet
assigné pour l'instant (voir la fixture `user_game` ci-dessous) —
pour les tests qui vérifient qu'un rôle non-admin voit/peut moins de
choses qu'un admin (ex. galerie de sprites animaux CraftPix, voir
core/sprite_gate.py)."""
_TEST_USER_COUNTER[0] += 1
email = f"pytest-user-{_TEST_USER_COUNTER[0]}@pytest.local"
user_id = auth.create_user(email, "Test1234!", "Test", "User")
auth.confirm_totp(user_id)
flask_app.config["TESTING"] = True
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["user_id"] = user_id
yield c
@pytest.fixture
def user_game(client, user_client):
"""Un jeu appartenant au compte `user_client` (project_slug assigné,
voir auth.set_project_slug — même mécanisme que l'inscription réelle,
routes/auth/register_2fa.py). Créé via le client ADMIN (`client`,
illimité) puis rattaché, pour ne pas dépendre du parcours
d'inscription complet dans les tests qui n'en ont pas besoin."""
resp = client.post("/games/new", data={"name": "pytest_user_game"}, follow_redirects=False)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
with user_client.session_transaction() as sess:
auth.set_project_slug(sess["user_id"], slug)
yield slug
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
@pytest.fixture
def game(client):
"""Crée un jeu de test frais et le supprime après le test, quel que
+160
View File
@@ -0,0 +1,160 @@
"""Bibliothèque de sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY,
screens/labels/animal_sprite_library.py) — réservée au rôle admin
(licence CraftPix, voir core/sprite_gate.py et le plan "Bibliothèque de
sprites animaux CraftPix"). Un compte "user" ne doit ni la voir dans la
galerie de l'éditeur, ni pouvoir l'assigner par un POST direct."""
import json
import screens
def test_admin_sprite_library_loaded_from_manifest():
assert len(screens.ADMIN_SPRITE_LIBRARY) > 0
bunny = screens.ADMIN_SPRITE_LIBRARY["animal-bunny-01"]
assert bunny["family"] == "bunny"
assert bunny["animations"]["idle"][0] == "/static/characters/animals/bunny/01/idle0.png"
assert len(bunny["animations"]["idle"]) == 20
def test_sprite_library_is_public_plus_admin_merged():
assert set(screens.SPRITE_LIBRARY) == set(screens.PUBLIC_SPRITE_LIBRARY) | set(screens.ADMIN_SPRITE_LIBRARY)
assert screens.ADMIN_ONLY_CHARACTER_SLUGS == set(screens.ADMIN_SPRITE_LIBRARY)
assert "male-adventurer" not in screens.ADMIN_ONLY_CHARACTER_SLUGS
def test_sprite_gallery_families_shows_one_tile_per_animal():
families = screens.sprite_gallery_families(screens.SPRITE_LIBRARY)
slugs = [slug for slug, _ in families]
assert len(slugs) == len(screens.PUBLIC_SPRITE_LIBRARY) + 14 # 6 Kenney + 14 familles animales
assert "animal-bunny-01" in slugs
assert "animal-bunny-02" not in slugs
def test_document_editor_hides_animal_characters_from_a_user_account(user_game, user_client):
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
html = user_client.get(f"/game/{user_game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "animal-bunny-01" not in html
assert "male-adventurer" in html
def test_document_editor_shows_animal_characters_to_admin(game, client):
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "animal-bunny-01" in html
def _create_jeu2d_game_as(post_client, name, slug_owner_client=None):
resp = post_client.post("/games/new", data={"name": name, "game_type": "jeu_2d"}, follow_redirects=False)
assert resp.status_code == 302
return resp.headers["Location"].rstrip("/").split("/")[-1]
def test_scene_editor_hides_animal_characters_from_a_user_account(client, user_client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_user"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
html = user_client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "animal-bunny-01" not in html
def test_scene_object_add_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_gate"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
resp = user_client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data={"kind": "personnage", "forge_character": "animal-bunny-01"},
)
assert resp.status_code == 403
def test_scene_object_add_allows_animal_character_for_admin(client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_admin"))
screen_id = screens.create_screen(slug, "Scène 1")
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data={"kind": "personnage", "forge_character": "animal-bunny-01"},
follow_redirects=False,
)
assert resp.status_code == 302
obj_id = int(resp.headers["Location"].rsplit("selected=", 1)[1])
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_personnage_data(obj)["forge_character"] == "animal-bunny-01"
def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_swap_gate"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
obj_id = screens.add_scene_object(slug, screen_id)
resp = user_client.post(
f"/game/{slug}/scene-objects/{obj_id}/personnage",
data=json.dumps({"source": "forge", "forge_character": "animal-bunny-01"}),
content_type="application/json",
)
assert resp.status_code == 403
def test_element_add_rejects_animal_character_for_a_user_account(user_game, user_client):
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
resp = user_client.post(
f"/game/{user_game}/screens/{screen_id}/elements/add",
data={"widget": "personnage", "forge_character": "animal-bunny-01"},
)
assert resp.status_code == 403
def test_element_set_personnage_data_rejects_animal_character_for_a_user_account(user_game, user_client):
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
resp = user_client.post(
f"/game/{user_game}/screens/{screen_id}/elements/add", data={"widget": "personnage"}, follow_redirects=False
)
element_id = int(resp.headers["Location"].rsplit("selected=", 1)[1])
resp = user_client.post(
f"/game/{user_game}/elements/{element_id}/personnage",
data=json.dumps({"source": "forge", "forge_character": "animal-bunny-01"}),
content_type="application/json",
)
assert resp.status_code == 403
def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_used_characters"))
screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
screens.add_scene_object(slug, screen_id, kind="decor")
used = screens.list_used_forge_characters(slug)
assert used == {"animal-bunny-01"}
def test_build_package_only_copies_used_animal_character_folders(client, tmp_game_slug_cleanup, tmp_path):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_publish_animals"))
screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
from publish import build_package
zip_path, _name = build_package.build_game_zip(slug)
try:
import zipfile
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
animal_paths = {n for n in names if "static/characters/animals/" in n and not n.endswith("manifest.json")}
animals_used = {n.split("static/characters/animals/")[1].split("/")[0] for n in animal_paths}
assert animals_used == {"bunny"}
assert any("static/characters/animals/bunny/01/" in n for n in animal_paths)
assert not any("static/characters/animals/bear/" in n for n in animal_paths)
assert any(n.endswith("static/characters/animals/manifest.json") for n in names)
assert any("static/characters/male-adventurer/" in n for n in names)
finally:
import os
os.remove(zip_path)
+14 -3
View File
@@ -117,10 +117,16 @@ def test_resolve_personnage_idle_frame_falls_back_to_first_animation(client, gam
def test_screen_edit_exposes_personnage_gallery_and_scripts(client, game):
"""Les 6 Kenney (screens.PUBLIC_SPRITE_LIBRARY) ont chacun leur tile —
les sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY), eux, sont
groupés par famille dans cette même galerie (un seul tile par animal,
voir screens.sprite_gallery_families et le plan "Bibliothèque de
sprites animaux CraftPix"), pas testés ici un par un."""
screen_id = _create_screen(client, game)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
for slug in screens.SPRITE_LIBRARY:
for slug in screens.PUBLIC_SPRITE_LIBRARY:
assert f"/static/characters/{slug}/idle.png" in html
assert "/static/characters/animals/bunny/01/idle0.png" in html
assert "personnageGallery" in html
# L'import de sprites personnalisés est retiré pour le moment.
assert "Sprite personnalisé" not in html
@@ -130,11 +136,16 @@ def test_screen_edit_exposes_personnage_gallery_and_scripts(client, game):
def test_sprite_library_has_all_six_characters_with_all_animations(client, game):
assert set(screens.SPRITE_LIBRARY) == {
"""screens.PUBLIC_SPRITE_LIBRARY (les 6 Kenney, CC0) — distinct de
screens.SPRITE_LIBRARY, qui inclut aussi les sprites animaux CraftPix
(screens.ADMIN_SPRITE_LIBRARY, réservés au rôle admin, voir le plan
"Bibliothèque de sprites animaux CraftPix" et
test_admin_sprite_library_loaded_from_manifest)."""
assert set(screens.PUBLIC_SPRITE_LIBRARY) == {
"male-adventurer", "female-adventurer", "male-person",
"female-person", "robot", "zombie",
}
for character in screens.SPRITE_LIBRARY.values():
for character in screens.PUBLIC_SPRITE_LIBRARY.values():
names = set(character["animations"])
assert {"idle", "walk", "run", "jump", "attack"} <= names
assert len(character["animations"]["walk"]) == 8