From f670735100484e11d9ea317e240be21fc5c18643 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Fri, 26 Jun 2026 14:38:39 +0200 Subject: [PATCH] Export/import par campagne + espace de jeu (v2), fix du nombre de joueurs, et ergonomie de jeu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export / import de contenu (format v2) - Export SÉLECTIF : on peut exporter une seule campagne (sa clôture : arcs → chapitres → scènes, PNJ, ennemis, catalogues, tables, système de jeu lié) au lieu de tout exporter. Options par export : inclure le lore, l'espace de jeu, les images. L'export global devient « Sauvegarde complète ». - ESPACE DE JEU désormais exporté/importé : parties (Playthrough), séances, journal, flags, progression de quêtes, et feuilles de perso (Character.playthroughId préservé au lieu d'être annulé). Remapping complet des références à l'import. - Format v2 rétro-compatible : un export v1 (sans section jeu) s'importe toujours (champs absents → null → traités comme vides). - Correction d'un bug latent : collectReferencedStorageKeys traitait les références d'images comme des clés de stockage alors que ce sont des IDs → les binaires d'images n'étaient en réalité pas embarqués dans le zip. Résolution ID → clé. - Images des Rooms d'une scène (illustration + plan) désormais collectées (elles étaient oubliées des deux collecteurs). - Endpoint : GET /api/admin/data/export?campaignId=&includeLore=&includePlay=&includeImages= ; écran « Sauvegarde des données » avec choix campagne + cases à cocher. - Tests : round-trip étendu au jeu + binaire image réel, export sélectif par campagne, import d'archive v1, et DataTransferController. Correction : nombre de joueurs non persisté - playerCount était saisi à la création mais jeté par le backend (absent du domaine/entité/DTO/mapper) → affichage « 0 joueurs ». Ajouté sur toute la stack + migration V7 (défaut 4 pour les campagnes existantes) + intégré à l'export. Ergonomie de l'espace de jeu et des listes - Description de campagne repliable (clamp 3 lignes + « Voir plus / moins »). - Dossiers repliables (chevron + compteur) dans les listes PNJ et Ennemis, sur la page campagne ET les pages dédiées. - Renommage en place des parties (« Mes parties ») via un crayon. - Panneau de session : onglets sur 2 rangs (« Scènes » n'est plus rogné) ; scènes triées par ordre manuel ; arcs repliables (repliés par défaut) ; PJ/PNJ repliables et PNJ regroupés par dossier. --- .../campaigncontext/CampaignService.java | 4 +- .../ConversationService.java | 2 - .../domain/campaigncontext/Campaign.java | 3 + .../persistence/entity/CampaignJpaEntity.java | 3 + .../postgres/PostgresCampaignRepository.java | 2 + .../transfer/ExportRequest.java | 31 ++ .../transfer/ExportService.java | 301 ++++++++++++++---- .../transfer/ImportService.java | 115 ++++++- .../transfer/dto/ContentExport.java | 61 +++- .../web/controller/CampaignController.java | 4 +- .../controller/DataTransferController.java | 39 ++- .../web/dto/campaigncontext/CampaignDTO.java | 2 + .../web/mapper/CampaignMapper.java | 2 + .../migration/V7__campaign_player_count.sql | 5 + .../campaigncontext/CampaignServiceTest.java | 15 +- .../ContentExportImportRoundTripTest.java | 182 +++++++---- .../transfer/ContentExportV1ImportTest.java | 76 +++++ .../transfer/SelectiveExportTest.java | 162 ++++++++++ .../DataTransferControllerTest.java | 95 ++++++ .../campaign-detail.component.html | 70 ++-- .../campaign-detail.component.scss | 47 +++ .../campaign-detail.component.ts | 45 ++- .../enemy-list/enemy-list.component.html | 45 +-- .../enemy-list/enemy-list.component.scss | 8 +- .../enemy/enemy-list/enemy-list.component.ts | 17 +- .../npc/npc-list/npc-list.component.html | 39 ++- .../npc/npc-list/npc-list.component.scss | 8 +- .../npc/npc-list/npc-list.component.ts | 17 +- web/src/app/services/settings.service.ts | 20 +- .../session-reference-panel.component.html | 94 +++--- .../session-reference-panel.component.scss | 63 +++- .../session-reference-panel.component.ts | 50 ++- web/src/app/settings/settings.component.html | 21 ++ web/src/app/settings/settings.component.scss | 47 +++ web/src/app/settings/settings.component.ts | 32 +- web/src/assets/i18n/en.json | 9 + web/src/assets/i18n/fr.json | 9 + 37 files changed, 1487 insertions(+), 258 deletions(-) create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/ExportRequest.java create mode 100644 core/src/main/resources/db/migration/V7__campaign_player_count.sql create mode 100644 core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java create mode 100644 core/src/test/java/com/loremind/infrastructure/transfer/SelectiveExportTest.java create mode 100644 core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java index 7701c20..ccb57de 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java @@ -48,7 +48,7 @@ public class CampaignService { this.playthroughService = playthroughService; } - public record CampaignData(String name, String description, String loreId, String gameSystemId) {} + public record CampaignData(String name, String description, String loreId, String gameSystemId, int playerCount) {} public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {} @@ -59,6 +59,7 @@ public class CampaignService { .loreId(normalizeId(data.loreId())) .gameSystemId(normalizeId(data.gameSystemId())) .arcsCount(0) + .playerCount(data.playerCount()) .build(); Campaign saved = campaignRepository.save(campaign); @@ -87,6 +88,7 @@ public class CampaignService { campaign.setDescription(data.description()); campaign.setLoreId(normalizeId(data.loreId())); campaign.setGameSystemId(normalizeId(data.gameSystemId())); + campaign.setPlayerCount(data.playerCount()); return campaignRepository.save(campaign); } diff --git a/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java b/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java index 172a49d..19bef2b 100644 --- a/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java +++ b/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java @@ -11,11 +11,9 @@ import java.util.Optional; /** * Service d'application du contexte Conversation. - * * Regroupe les cas d'usage CRUD + append message + rename. Un seul * service suffit — le contexte est simple et les operations fortement * liees (meme aggregat). - * * Regles metier : * - exactement un ancrage parent (loreId XOR campaignId) ; * - entityType et entityId vont ensemble (tous deux null = niveau racine, diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java index e6dfee1..feec046 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java @@ -24,6 +24,9 @@ public class Campaign { private LocalDateTime updatedAt; private int arcsCount; + /** Nombre de joueurs attendus à la table (métadonnée saisie à la création). */ + private int playerCount; + /** * Référence faible vers un Lore. Nullable. * Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context. diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java index 886fcdf..1c33c15 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java @@ -37,6 +37,9 @@ public class CampaignJpaEntity { @Column(name = "arcs_count", nullable = false) private int arcsCount; + @Column(name = "player_count", nullable = false) + private int playerCount; + /** * ID du Lore associé (nullable). * Pas de @ManyToOne / pas de FK : c'est une weak reference inter-contexte. diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java index 5023bbc..cdc036e 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java @@ -73,6 +73,7 @@ public class PostgresCampaignRepository implements CampaignRepository { .createdAt(jpaEntity.getCreatedAt()) .updatedAt(jpaEntity.getUpdatedAt()) .arcsCount(jpaEntity.getArcsCount()) + .playerCount(jpaEntity.getPlayerCount()) .loreId(jpaEntity.getLoreId()) .gameSystemId(jpaEntity.getGameSystemId()) .build(); @@ -87,6 +88,7 @@ public class PostgresCampaignRepository implements CampaignRepository { .createdAt(campaign.getCreatedAt()) .updatedAt(campaign.getUpdatedAt()) .arcsCount(campaign.getArcsCount()) + .playerCount(campaign.getPlayerCount()) .loreId(campaign.getLoreId()) .gameSystemId(campaign.getGameSystemId()) .build(); diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportRequest.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportRequest.java new file mode 100644 index 0000000..edcea92 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportRequest.java @@ -0,0 +1,31 @@ +package com.loremind.infrastructure.transfer; + +/** + * Périmètre d'un export de contenu. + *

+ * - {@code campaignId == null} → Sauvegarde complète : tout le contenu de la base.
+ * - {@code campaignId != null} → export ciblé : la campagne et sa clôture (arcs → + * chapitres → scènes, PNJ, ennemis, catalogues, tables, son système de jeu), plus + * éventuellement son univers (lore) et son espace de jeu (parties/sessions/feuilles de perso), + * selon les options. + *

+ * Les options ne s'appliquent qu'à l'export ciblé ; une sauvegarde complète prend tout. + * + * @param campaignId campagne à exporter, ou {@code null} pour tout exporter + * @param includeLore inclure l'univers lié (lore, dossiers, templates, pages) + * @param includePlay inclure l'espace de jeu (parties, sessions, journal, flags, + * progression de quêtes, feuilles de personnages) + * @param includeImages inclure les binaires d'images/fichiers référencés (sinon : métadonnées seules) + */ +public record ExportRequest(Long campaignId, boolean includeLore, boolean includePlay, boolean includeImages) { + + /** Sauvegarde complète : toute la base, jeu et images compris. */ + public static ExportRequest full() { + return new ExportRequest(null, true, true, true); + } + + /** Vrai si l'export couvre toute la base (pas de campagne ciblée). */ + public boolean isFull() { + return campaignId == null; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java index fea16b7..bcc01de 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java @@ -2,6 +2,7 @@ package com.loremind.infrastructure.transfer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.loremind.domain.campaigncontext.Room; import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.images.ports.ImageStorage; import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; @@ -39,7 +40,7 @@ import java.util.zip.ZipOutputStream; @Service public class ExportService { - private static final int FORMAT_VERSION = 1; + private static final int FORMAT_VERSION = 2; // Réutilise le converter JPA pour (dé)sérialiser les prérequis dans le MÊME // format que la base (discriminant "kind"), au lieu de Jackson polymorphe. @@ -63,6 +64,11 @@ public class ExportService { private final ImageStorage imageStorage; private final StoredFileJpaRepository storedFileRepo; private final FileStorage fileStorage; + private final PlaythroughJpaRepository playthroughRepo; + private final SessionJpaRepository sessionRepo; + private final SessionEntryJpaRepository sessionEntryRepo; + private final PlaythroughFlagJpaRepository playthroughFlagRepo; + private final QuestProgressionJpaRepository questProgressionRepo; private final ObjectMapper objectMapper; private final String appVersion; @@ -84,6 +90,11 @@ public class ExportService { ImageStorage imageStorage, StoredFileJpaRepository storedFileRepo, FileStorage fileStorage, + PlaythroughJpaRepository playthroughRepo, + SessionJpaRepository sessionRepo, + SessionEntryJpaRepository sessionEntryRepo, + PlaythroughFlagJpaRepository playthroughFlagRepo, + QuestProgressionJpaRepository questProgressionRepo, ObjectMapper objectMapper, @Nullable BuildProperties buildProperties) { this.gameSystemRepo = gameSystemRepo; @@ -104,55 +115,183 @@ public class ExportService { this.imageStorage = imageStorage; this.storedFileRepo = storedFileRepo; this.fileStorage = fileStorage; + this.playthroughRepo = playthroughRepo; + this.sessionRepo = sessionRepo; + this.sessionEntryRepo = sessionEntryRepo; + this.playthroughFlagRepo = playthroughFlagRepo; + this.questProgressionRepo = questProgressionRepo; this.objectMapper = objectMapper; this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev"; } + /** Sauvegarde complète (rétro-compat) : tout le contenu de la base. */ + public ContentExport buildExport(String exportedAt) { + return buildExport(exportedAt, ExportRequest.full()); + } + /** - * Charge toutes les entites du perimetre et les mappe vers les DTO plats. + * Construit l'export selon le périmètre demandé : {@link ExportRequest#isFull() sauvegarde + * complète} (toute la base) ou export ciblé d'une campagne et de sa clôture. * * @param exportedAt horodatage ISO stampe par la couche appelante (controller) */ - public ContentExport buildExport(String exportedAt) { + public ContentExport buildExport(String exportedAt, ExportRequest req) { + return req.isFull() ? buildFullExport(exportedAt) : buildCampaignExport(exportedAt, req); + } + + private ContentExport buildFullExport(String exportedAt) { ContentExport.Manifest manifest = - new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt); + new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt, "complète"); + return new ContentExport(manifest, + map(gameSystemRepo.findAll(), this::toGameSystemDto), + map(loreRepo.findAll(), this::toLoreDto), + map(loreNodeRepo.findAll(), this::toLoreNodeDto), + map(templateRepo.findAll(), this::toTemplateDto), + map(pageRepo.findAll(), this::toPageDto), + map(campaignRepo.findAll(), this::toCampaignDto), + map(arcRepo.findAll(), this::toArcDto), + map(chapterRepo.findAll(), this::toChapterDto), + map(sceneRepo.findAll(), this::toSceneDto), + map(characterRepo.findAll(), this::toCharacterDto), + map(npcRepo.findAll(), this::toNpcDto), + map(enemyRepo.findAll(), this::toEnemyDto), + map(itemCatalogRepo.findAll(), this::toItemCatalogDto), + map(randomTableRepo.findAll(), this::toRandomTableDto), + map(imageRepo.findAll(), this::toImageDto), + map(storedFileRepo.findAll(), this::toStoredFileDto), + map(playthroughRepo.findAll(), this::toPlaythroughDto), + map(sessionRepo.findAll(), this::toSessionDto), + map(sessionEntryRepo.findAll(), this::toSessionEntryDto), + map(playthroughFlagRepo.findAll(), this::toFlagDto), + map(questProgressionRepo.findAll(), this::toQuestProgressionDto)); + } - List gameSystems = gameSystemRepo.findAll().stream() - .map(this::toGameSystemDto).toList(); - List lores = loreRepo.findAll().stream() - .map(this::toLoreDto).toList(); - List loreNodes = loreNodeRepo.findAll().stream() - .map(this::toLoreNodeDto).toList(); - List templates = templateRepo.findAll().stream() - .map(this::toTemplateDto).toList(); - List pages = pageRepo.findAll().stream() - .map(this::toPageDto).toList(); - List campaigns = campaignRepo.findAll().stream() - .map(this::toCampaignDto).toList(); - List arcs = arcRepo.findAll().stream() - .map(this::toArcDto).toList(); - List chapters = chapterRepo.findAll().stream() - .map(this::toChapterDto).toList(); - List scenes = sceneRepo.findAll().stream() - .map(this::toSceneDto).toList(); - List characters = characterRepo.findAll().stream() - .map(this::toCharacterDto).toList(); - List npcs = npcRepo.findAll().stream() - .map(this::toNpcDto).toList(); - List enemies = enemyRepo.findAll().stream() - .map(this::toEnemyDto).toList(); - List itemCatalogs = itemCatalogRepo.findAll().stream() - .map(this::toItemCatalogDto).toList(); - List randomTables = randomTableRepo.findAll().stream() - .map(this::toRandomTableDto).toList(); - List images = imageRepo.findAll().stream() - .map(this::toImageDto).toList(); - List storedFiles = storedFileRepo.findAll().stream() - .map(this::toStoredFileDto).toList(); + /** + * Export CIBLÉ : la campagne et sa clôture (arcs → chapitres → scènes, PNJ, ennemis, + * catalogues, tables, système de jeu lié), plus — selon les options — son univers (lore) + * et son espace de jeu (parties → sessions/journal/flags/quêtes + feuilles de perso). + * Les images/fichiers exportés sont uniquement ceux RÉFÉRENCÉS par la clôture. + */ + private ContentExport buildCampaignExport(String exportedAt, ExportRequest req) { + Long cid = req.campaignId(); + CampaignJpaEntity campaign = campaignRepo.findById(cid) + .orElseThrow(() -> new java.util.NoSuchElementException("Campagne introuvable : " + cid)); - return new ContentExport(manifest, gameSystems, lores, loreNodes, templates, - pages, campaigns, arcs, chapters, scenes, characters, npcs, enemies, - itemCatalogs, randomTables, images, storedFiles); + // Prep : clôture structurelle de la campagne. + List arcEntities = arcRepo.findByCampaignId(cid); + List chapterEntities = arcEntities.stream() + .flatMap(a -> chapterRepo.findByArcId(a.getId()).stream()).toList(); + List sceneEntities = chapterEntities.stream() + .flatMap(c -> sceneRepo.findByChapterId(c.getId()).stream()).toList(); + List npcEntities = npcRepo.findByCampaignIdOrderByOrderAsc(cid); + List enemyEntities = enemyRepo.findByCampaignIdOrderByOrderAsc(cid); + List catalogEntities = itemCatalogRepo.findByCampaignIdOrderByOrderAsc(cid); + List tableEntities = randomTableRepo.findByCampaignIdOrderByOrderAsc(cid); + + // Système de jeu lié : TOUJOURS inclus (templates/PDF en dépendent). + List gsEntities = singleton(gameSystemRepo, parseLongOrNull(campaign.getGameSystemId())); + + // Univers (lore) lié : optionnel. + Long lid = req.includeLore() ? parseLongOrNull(campaign.getLoreId()) : null; + List loreEntities = lid != null ? singleton(loreRepo, lid) : List.of(); + List loreNodeEntities = lid != null ? loreNodeRepo.findByLoreId(lid) : List.of(); + List templateEntities = lid != null ? templateRepo.findByLoreId(lid) : List.of(); + List pageEntities = lid != null ? pageRepo.findByLoreId(lid) : List.of(); + + // Espace de jeu : optionnel. Les feuilles de perso appartiennent à une Partie, + // donc « sans jeu » = sans feuilles de perso. + List ptEntities = req.includePlay() ? playthroughRepo.findByCampaignId(cid) : List.of(); + List sessionEntities = ptEntities.stream() + .flatMap(p -> sessionRepo.findByPlaythroughIdOrderByStartedAtDesc(p.getId()).stream()).toList(); + List entryEntities = sessionEntities.stream() + .flatMap(s -> sessionEntryRepo.findBySessionIdOrderByOccurredAtAsc(String.valueOf(s.getId())).stream()).toList(); + List flagEntities = ptEntities.stream() + .flatMap(p -> playthroughFlagRepo.findByPlaythroughId(p.getId()).stream()).toList(); + List questEntities = ptEntities.stream() + .flatMap(p -> questProgressionRepo.findByPlaythroughId(p.getId()).stream()).toList(); + List characterEntities = ptEntities.stream() + .flatMap(p -> characterRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList(); + + // Images/fichiers : uniquement les binaires RÉFÉRENCÉS par la clôture (si option active). + List imageEntities = List.of(); + List fileEntities = List.of(); + if (req.includeImages()) { + Set imageRefs = new LinkedHashSet<>(); + arcEntities.forEach(a -> addAll(imageRefs, a.getIllustrationImageIds())); + chapterEntities.forEach(c -> addAll(imageRefs, c.getIllustrationImageIds())); + sceneEntities.forEach(s -> addAll(imageRefs, s.getIllustrationImageIds())); + sceneEntities.forEach(s -> addRoomImageRefs(imageRefs, s.getRooms())); + npcEntities.forEach(n -> { add(imageRefs, n.getPortraitImageId()); add(imageRefs, n.getHeaderImageId()); addImageValues(imageRefs, n.getImageValues()); }); + enemyEntities.forEach(e -> { add(imageRefs, e.getPortraitImageId()); add(imageRefs, e.getHeaderImageId()); addImageValues(imageRefs, e.getImageValues()); }); + characterEntities.forEach(c -> { add(imageRefs, c.getPortraitImageId()); add(imageRefs, c.getHeaderImageId()); addImageValues(imageRefs, c.getImageValues()); }); + pageEntities.forEach(p -> addImageValues(imageRefs, p.getImageValues())); + imageEntities = imageRefs.stream() + .map(ExportService::parseLongOrNull).filter(java.util.Objects::nonNull) + .map(id -> imageRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull) + .distinct().toList(); + + Set fileRefs = new LinkedHashSet<>(); + sceneEntities.forEach(s -> { addLong(fileRefs, s.getBattlemapMediaFileId()); addLong(fileRefs, s.getBattlemapDataFileId()); }); + fileEntities = fileRefs.stream() + .map(id -> storedFileRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull).toList(); + } + + // Campaign DTO : si le lore n'est pas exporté, on neutralise loreId (évite une + // référence pendante vers un univers absent à l'import). + ContentExport.CampaignDto campaignDto = toCampaignDto(campaign); + if (!req.includeLore()) { + campaignDto = new ContentExport.CampaignDto(campaignDto.id(), campaignDto.name(), + campaignDto.description(), campaignDto.arcsCount(), campaignDto.playerCount(), + null, campaignDto.gameSystemId()); + } + + ContentExport.Manifest manifest = + new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt, campaign.getName()); + return new ContentExport(manifest, + map(gsEntities, this::toGameSystemDto), + map(loreEntities, this::toLoreDto), + map(loreNodeEntities, this::toLoreNodeDto), + map(templateEntities, this::toTemplateDto), + map(pageEntities, this::toPageDto), + List.of(campaignDto), + map(arcEntities, this::toArcDto), + map(chapterEntities, this::toChapterDto), + map(sceneEntities, this::toSceneDto), + map(characterEntities, this::toCharacterDto), + map(npcEntities, this::toNpcDto), + map(enemyEntities, this::toEnemyDto), + map(catalogEntities, this::toItemCatalogDto), + map(tableEntities, this::toRandomTableDto), + map(imageEntities, this::toImageDto), + map(fileEntities, this::toStoredFileDto), + map(ptEntities, this::toPlaythroughDto), + map(sessionEntities, this::toSessionDto), + map(entryEntities, this::toSessionEntryDto), + map(flagEntities, this::toFlagDto), + map(questEntities, this::toQuestProgressionDto)); + } + + // ----- Helpers de chargement ----- + + private static List map(List in, java.util.function.Function f) { + return in.stream().map(f).toList(); + } + + /** Liste 0/1 élément : l'entité d'id donné si présente. */ + private static > + List singleton(R repo, Long id) { + if (id == null) return List.of(); + return repo.findById(id).map(List::of).orElseGet(List::of); + } + + private static Long parseLongOrNull(String s) { + if (s == null || s.isBlank()) return null; + try { return Long.parseLong(s.trim()); } catch (NumberFormatException e) { return null; } + } + + private static void addLong(Set set, String idStr) { + Long id = parseLongOrNull(idStr); + if (id != null) set.add(id); } /** @@ -222,33 +361,39 @@ public class ExportService { * (portrait + header + imageValues), Page (imageValues). */ private Set collectReferencedStorageKeys(ContentExport export) { - Set keys = new LinkedHashSet<>(); - for (ContentExport.ArcDto a : export.arcs()) { - addAll(keys, a.illustrationImageIds()); - } - for (ContentExport.ChapterDto c : export.chapters()) { - addAll(keys, c.illustrationImageIds()); - } - for (ContentExport.SceneDto s : export.scenes()) { - addAll(keys, s.illustrationImageIds()); + // Les entités référencent les images par ID (cf. Image.getId() renvoyé à l'upload), + // PAS par clé de stockage. On résout donc ID -> storageKey via l'index des images + // exportées — même logique que collectReferencedFileStorageKeys pour les fichiers. + java.util.Map keyByImageId = new java.util.HashMap<>(); + for (ContentExport.ImageDto img : export.images()) { + if (img.id() != null) keyByImageId.put(img.id().toString(), img.storageKey()); } + Set refs = new LinkedHashSet<>(); + for (ContentExport.ArcDto a : export.arcs()) addAll(refs, a.illustrationImageIds()); + for (ContentExport.ChapterDto c : export.chapters()) addAll(refs, c.illustrationImageIds()); + for (ContentExport.SceneDto s : export.scenes()) addAll(refs, s.illustrationImageIds()); + for (ContentExport.SceneDto s : export.scenes()) addRoomImageRefs(refs, s.rooms()); for (ContentExport.CharacterDto c : export.characters()) { - add(keys, c.portraitImageId()); - add(keys, c.headerImageId()); - addImageValues(keys, c.imageValues()); + add(refs, c.portraitImageId()); + add(refs, c.headerImageId()); + addImageValues(refs, c.imageValues()); } for (ContentExport.NpcDto n : export.npcs()) { - add(keys, n.portraitImageId()); - add(keys, n.headerImageId()); - addImageValues(keys, n.imageValues()); + add(refs, n.portraitImageId()); + add(refs, n.headerImageId()); + addImageValues(refs, n.imageValues()); } for (ContentExport.EnemyDto e : export.enemies()) { - add(keys, e.portraitImageId()); - add(keys, e.headerImageId()); - addImageValues(keys, e.imageValues()); + add(refs, e.portraitImageId()); + add(refs, e.headerImageId()); + addImageValues(refs, e.imageValues()); } - for (ContentExport.PageDto p : export.pages()) { - addImageValues(keys, p.imageValues()); + for (ContentExport.PageDto p : export.pages()) addImageValues(refs, p.imageValues()); + + Set keys = new LinkedHashSet<>(); + for (String ref : refs) { + String key = keyByImageId.get(ref); + if (key != null && !key.isBlank()) keys.add(key); } return keys; } @@ -288,6 +433,15 @@ public class ExportService { if (imageValues != null) imageValues.values().forEach(l -> addAll(keys, l)); } + /** Réfs d'images portées par les salles (Room) d'une scène : galerie + plan. */ + private void addRoomImageRefs(Set keys, List rooms) { + if (rooms == null) return; + for (Room r : rooms) { + addAll(keys, r.getIllustrationImageIds()); + add(keys, r.getMapImageId()); + } + } + // ----- Mappers entite -> DTO ----- private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) { @@ -320,7 +474,7 @@ public class ExportService { private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) { return new ContentExport.CampaignDto(e.getId(), e.getName(), e.getDescription(), - e.getArcsCount(), e.getLoreId(), e.getGameSystemId()); + e.getArcsCount(), e.getPlayerCount(), e.getLoreId(), e.getGameSystemId()); } private ContentExport.ArcDto toArcDto(ArcJpaEntity e) { @@ -400,4 +554,31 @@ public class ExportService { return new ContentExport.StoredFileDto(e.getId(), e.getFilename(), e.getContentType(), e.getSizeBytes(), e.getStorageKey()); } + + // ----- Mappers espace de jeu ----- + + private ContentExport.PlaythroughDto toPlaythroughDto(PlaythroughJpaEntity e) { + return new ContentExport.PlaythroughDto(e.getId(), e.getCampaignId(), e.getName(), e.getDescription()); + } + + private ContentExport.SessionDto toSessionDto(SessionJpaEntity e) { + return new ContentExport.SessionDto(e.getId(), e.getName(), e.getCampaignId(), e.getPlaythroughId(), + e.getStartedAt() != null ? e.getStartedAt().toString() : null, + e.getEndedAt() != null ? e.getEndedAt().toString() : null); + } + + private ContentExport.SessionEntryDto toSessionEntryDto(SessionEntryJpaEntity e) { + return new ContentExport.SessionEntryDto(e.getId(), e.getSessionId(), + e.getType() != null ? e.getType().name() : null, e.getContent(), + e.getOccurredAt() != null ? e.getOccurredAt().toString() : null); + } + + private ContentExport.PlaythroughFlagDto toFlagDto(PlaythroughFlagJpaEntity e) { + return new ContentExport.PlaythroughFlagDto(e.getId(), e.getPlaythroughId(), e.getName(), e.isValue()); + } + + private ContentExport.QuestProgressionDto toQuestProgressionDto(QuestProgressionJpaEntity e) { + return new ContentExport.QuestProgressionDto(e.getId(), e.getPlaythroughId(), e.getChapterId(), + e.getStatus() != null ? e.getStatus().name() : null); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java index 67bb060..229d762 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java @@ -1,6 +1,8 @@ package com.loremind.infrastructure.transfer; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.playcontext.EntryType; import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.jpa.*; @@ -61,6 +63,11 @@ public class ImportService { private final EnemyJpaRepository enemyRepo; private final ItemCatalogJpaRepository itemCatalogRepo; private final RandomTableJpaRepository randomTableRepo; + private final PlaythroughJpaRepository playthroughRepo; + private final SessionJpaRepository sessionRepo; + private final SessionEntryJpaRepository sessionEntryRepo; + private final PlaythroughFlagJpaRepository playthroughFlagRepo; + private final QuestProgressionJpaRepository questProgressionRepo; private final ImageImporter imageImporter; private final StoredFileImporter storedFileImporter; private final ObjectMapper objectMapper; @@ -79,6 +86,11 @@ public class ImportService { EnemyJpaRepository enemyRepo, ItemCatalogJpaRepository itemCatalogRepo, RandomTableJpaRepository randomTableRepo, + PlaythroughJpaRepository playthroughRepo, + SessionJpaRepository sessionRepo, + SessionEntryJpaRepository sessionEntryRepo, + PlaythroughFlagJpaRepository playthroughFlagRepo, + QuestProgressionJpaRepository questProgressionRepo, ImageImporter imageImporter, StoredFileImporter storedFileImporter, ObjectMapper objectMapper) { @@ -96,6 +108,11 @@ public class ImportService { this.enemyRepo = enemyRepo; this.itemCatalogRepo = itemCatalogRepo; this.randomTableRepo = randomTableRepo; + this.playthroughRepo = playthroughRepo; + this.sessionRepo = sessionRepo; + this.sessionEntryRepo = sessionEntryRepo; + this.playthroughFlagRepo = playthroughFlagRepo; + this.questProgressionRepo = questProgressionRepo; this.imageImporter = imageImporter; this.storedFileImporter = storedFileImporter; this.objectMapper = objectMapper; @@ -126,6 +143,8 @@ public class ImportService { Map enemyMap = new HashMap<>(); Map characterMap = new HashMap<>(); Map sceneMap = new HashMap<>(); + Map playthroughMap = new HashMap<>(); + Map sessionMap = new HashMap<>(); // -- GameSystem for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) { @@ -206,12 +225,61 @@ public class ImportService { e.setName(d.name()); e.setDescription(d.description()); e.setArcsCount(d.arcsCount()); + e.setPlayerCount(d.playerCount()); e.setLoreId(d.loreId()); // remappe plus bas e.setGameSystemId(d.gameSystemId()); // remappe plus bas campaignMap.put(d.id(), campaignRepo.save(e).getId()); } result.count("campaigns", campaignMap.size()); + // -- Playthrough (Partie) : campaignId remappe + for (ContentExport.PlaythroughDto d : nullSafe(export.playthroughs())) { + PlaythroughJpaEntity e = new PlaythroughJpaEntity(); + e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); + e.setName(d.name()); + e.setDescription(d.description()); + playthroughMap.put(d.id(), playthroughRepo.save(e).getId()); + } + result.count("playthroughs", playthroughMap.size()); + + // -- Session : campaignId (ref faible String) + playthroughId remappes + for (ContentExport.SessionDto d : nullSafe(export.sessions())) { + SessionJpaEntity e = new SessionJpaEntity(); + e.setName(d.name()); + e.setCampaignId(IdRemapper.remapStringId(campaignMap, d.campaignId())); + e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); + e.setStartedAt(parseDateTime(d.startedAt())); + e.setEndedAt(parseDateTime(d.endedAt())); + sessionMap.put(d.id(), sessionRepo.save(e).getId()); + } + result.count("sessions", sessionMap.size()); + + // -- SessionEntry : sessionId (ref faible String) remappe + int sessionEntryCount = 0; + for (ContentExport.SessionEntryDto d : nullSafe(export.sessionEntries())) { + SessionEntryJpaEntity e = new SessionEntryJpaEntity(); + e.setSessionId(IdRemapper.remapStringId(sessionMap, d.sessionId())); + e.setType(parseEntryType(d.type())); + e.setContent(d.content()); + e.setOccurredAt(parseDateTime(d.occurredAt())); + sessionEntryRepo.save(e); + sessionEntryCount++; + } + result.count("sessionEntries", sessionEntryCount); + + // -- PlaythroughFlag : playthroughId remappe (la contrainte unique (playthroughId,name) + // ne saute pas, le playthroughId etant neuf). + int flagCount = 0; + for (ContentExport.PlaythroughFlagDto d : nullSafe(export.playthroughFlags())) { + PlaythroughFlagJpaEntity e = new PlaythroughFlagJpaEntity(); + e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); + e.setName(d.name()); + e.setValue(d.value()); + playthroughFlagRepo.save(e); + flagCount++; + } + result.count("playthroughFlags", flagCount); + // -- Arc (relatedPageIds remappe en 2e passe) for (ContentExport.ArcDto d : nullSafe(export.arcs())) { ArcJpaEntity e = new ArcJpaEntity(); @@ -307,6 +375,19 @@ public class ImportService { } result.count("chapters", chapterMap.size()); + // -- QuestProgression : playthroughId + chapterId remappes (chapitres deja inseres ; + // contrainte unique (playthroughId, chapterId) preservee car playthroughId neuf). + int questProgCount = 0; + for (ContentExport.QuestProgressionDto d : nullSafe(export.questProgressions())) { + QuestProgressionJpaEntity e = new QuestProgressionJpaEntity(); + e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); + e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId())); + e.setStatus(parseProgressionStatus(d.status())); + questProgressionRepo.save(e); + questProgCount++; + } + result.count("questProgressions", questProgCount); + // -- Npc (relatedPageIds remappe en 2e passe) for (ContentExport.NpcDto d : nullSafe(export.npcs())) { NpcJpaEntity e = new NpcJpaEntity(); @@ -353,7 +434,9 @@ public class ImportService { e.setImageValues(d.imageValues()); e.setKeyValueValues(d.keyValueValues()); e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); - e.setPlaythroughId(null); // Playthrough hors perimetre d'export + // playthroughId remappe vers la Partie importee (ou null si le jeu n'etait pas + // dans l'export -> la map est vide). Evite une reference pendante. + e.setPlaythroughId(playthroughMap.get(d.playthroughId())); e.setOrder(d.order()); characterMap.put(d.id(), characterRepo.save(e).getId()); } @@ -518,6 +601,36 @@ public class ImportService { return list != null ? list : List.of(); } + /** Parse un horodatage ISO LocalDateTime, ou null si absent/illisible. */ + private static java.time.LocalDateTime parseDateTime(String s) { + if (s == null || s.isBlank()) return null; + try { + return java.time.LocalDateTime.parse(s.trim()); + } catch (java.time.format.DateTimeParseException e) { + return null; + } + } + + /** Parse un EntryType, repli sur NOTE si inconnu/absent (jamais d'echec). */ + private static EntryType parseEntryType(String s) { + if (s == null) return EntryType.NOTE; + try { + return EntryType.valueOf(s); + } catch (IllegalArgumentException e) { + return EntryType.NOTE; + } + } + + /** Parse un ProgressionStatus, repli sur NOT_STARTED si inconnu/absent. */ + private static ProgressionStatus parseProgressionStatus(String s) { + if (s == null) return ProgressionStatus.NOT_STARTED; + try { + return ProgressionStatus.valueOf(s); + } catch (IllegalArgumentException e) { + return ProgressionStatus.NOT_STARTED; + } + } + private static byte[] readAll(InputStream in) throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); in.transferTo(buffer); diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java index 7958088..ea2d96f 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java @@ -36,18 +36,27 @@ public record ContentExport( List itemCatalogs, List randomTables, List images, - List storedFiles + List storedFiles, + // --- Espace de jeu (format v2). Absent des exports v1 -> listes null a la + // relecture (Jackson) -> traitees comme vides cote import (nullSafe). --- + List playthroughs, + List sessions, + List sessionEntries, + List playthroughFlags, + List questProgressions ) { /** * Metadonnees de l'export. {@code exportedAt} est passe en parametre depuis * la couche requete (PAS Instant.now() ici, pour rester deterministe et - * testable). + * testable). {@code scope} decrit le perimetre ("complète" ou nom de campagne) + * a titre informatif. */ public record Manifest( int formatVersion, String appVersion, - String exportedAt + String exportedAt, + String scope ) {} public record GameSystemDto( @@ -108,6 +117,7 @@ public record ContentExport( String name, String description, int arcsCount, + int playerCount, String loreId, String gameSystemId ) {} @@ -278,4 +288,49 @@ public record ContentExport( long sizeBytes, String storageKey ) {} + + // ===================================================================== Jeu (v2) + + /** Une Partie (Playthrough) : un run d'une campagne par un groupe. */ + public record PlaythroughDto( + Long id, + Long campaignId, + String name, + String description + ) {} + + /** Une séance de jeu rattachée à une Partie. Horodatages en ISO-8601 (ou null). */ + public record SessionDto( + Long id, + String name, + String campaignId, + Long playthroughId, + String startedAt, + String endedAt + ) {} + + /** Une entrée du journal d'une séance ({@code type} = nom de l'EntryType). */ + public record SessionEntryDto( + Long id, + String sessionId, + String type, + String content, + String occurredAt + ) {} + + /** Un drapeau de Partie (flag narratif booléen). */ + public record PlaythroughFlagDto( + Long id, + Long playthroughId, + String name, + boolean value + ) {} + + /** La progression d'une quête (Chapter) dans une Partie ({@code status} = nom du ProgressionStatus). */ + public record QuestProgressionDto( + Long id, + Long playthroughId, + Long chapterId, + String status + ) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java index 097cec7..1bb1e79 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java @@ -31,7 +31,7 @@ public class CampaignController { public ResponseEntity createCampaign(@RequestBody CampaignDTO campaignDTO) { Campaign campaign = campaignMapper.toDomain(campaignDTO); Campaign createdCampaign = campaignService.createCampaign( - new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId()) + new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId(), campaign.getPlayerCount()) ); return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign)); } @@ -64,7 +64,7 @@ public class CampaignController { public ResponseEntity updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) { Campaign updatedCampaign = campaignService.updateCampaign( id, - new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId()) + new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId(), campaignDTO.getPlayerCount()) ); return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign)); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java index 087d833..05e4c34 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java @@ -1,5 +1,6 @@ package com.loremind.infrastructure.web.controller; +import com.loremind.infrastructure.transfer.ExportRequest; import com.loremind.infrastructure.transfer.ExportService; import com.loremind.infrastructure.transfer.ImportResult; import com.loremind.infrastructure.transfer.ImportService; @@ -47,22 +48,52 @@ public class DataTransferController { this.demoMode = demoMode; } + /** + * Export portable. Sans {@code campaignId} : sauvegarde complète (toute la base). + * Avec {@code campaignId} : export ciblé d'une campagne et de sa clôture, les options + * {@code includeLore/Play/Images} (par défaut vraies) pilotant l'univers, l'espace de + * jeu et les binaires d'images embarqués. + */ @GetMapping(value = "/export", produces = "application/zip") - public ResponseEntity export() { + public ResponseEntity export( + @RequestParam(required = false) Long campaignId, + @RequestParam(defaultValue = "true") boolean includeLore, + @RequestParam(defaultValue = "true") boolean includePlay, + @RequestParam(defaultValue = "true") boolean includeImages) { guardDemoMode(); // Stamp de l'horodatage cote requete (PAS dans un init statique). String exportedAt = Instant.now().toString(); - ContentExport content = exportService.buildExport(exportedAt); + ExportRequest req = campaignId == null + ? ExportRequest.full() + : new ExportRequest(campaignId, includeLore, includePlay, includeImages); + + ContentExport content; + try { + content = exportService.buildExport(exportedAt, req); + } catch (java.util.NoSuchElementException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage()); + } StreamingResponseBody body = out -> exportService.writeZip(content, out); + String filename = "loremind-" + slug(content.manifest().scope()) + ".zip"; return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"loremind-export.zip\"") + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") .contentType(MediaType.parseMediaType("application/zip")) .body(body); } + /** Slug ASCII minuscule (pour le nom de fichier) à partir d'un libellé de scope. */ + private static String slug(String s) { + if (s == null || s.isBlank()) return "export"; + String slug = java.text.Normalizer.normalize(s, java.text.Normalizer.Form.NFD) + .replaceAll("\\p{M}+", "") + .toLowerCase() + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-+|-+$)", ""); + return slug.isBlank() ? "export" : slug; + } + @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity importData(@RequestParam("file") MultipartFile file) { guardDemoMode(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java index 6be48c4..d05c5c5 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java @@ -13,6 +13,8 @@ public class CampaignDTO { private String name; private String description; private int arcsCount; + /** Nombre de joueurs attendus à la table. */ + private int playerCount; /** Nullable : campagne sans univers associé. */ private String loreId; /** Nullable : campagne sans système de JDR associé (générique). */ diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java index c16a87b..9881f1d 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java @@ -20,6 +20,7 @@ public class CampaignMapper { dto.setName(campaign.getName()); dto.setDescription(campaign.getDescription()); dto.setArcsCount(campaign.getArcsCount()); + dto.setPlayerCount(campaign.getPlayerCount()); dto.setLoreId(campaign.getLoreId()); dto.setGameSystemId(campaign.getGameSystemId()); return dto; @@ -35,6 +36,7 @@ public class CampaignMapper { .name(dto.getName()) .description(dto.getDescription()) .arcsCount(dto.getArcsCount()) + .playerCount(dto.getPlayerCount()) .loreId(dto.getLoreId()) .gameSystemId(dto.getGameSystemId()) .build(); diff --git a/core/src/main/resources/db/migration/V7__campaign_player_count.sql b/core/src/main/resources/db/migration/V7__campaign_player_count.sql new file mode 100644 index 0000000..648c425 --- /dev/null +++ b/core/src/main/resources/db/migration/V7__campaign_player_count.sql @@ -0,0 +1,5 @@ +-- Nombre de joueurs attendus à la table. +-- Le champ était saisi à la création côté front (défaut 4) mais jamais persisté : +-- le backend (domaine/entité/DTO) ne le portait pas, d'où un affichage "0 joueurs". +-- Défaut 4 (valeur par défaut du formulaire) pour les campagnes existantes. +ALTER TABLE campaigns ADD COLUMN player_count INTEGER NOT NULL DEFAULT 4; diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java index 454a2e9..12da9b3 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java @@ -70,7 +70,8 @@ public class CampaignServiceTest { "New Campaign", "Description", "lore-123", - null + null, + 4 ); // Le repo renvoie la Campaign telle que passée — on teste la normalisation // du loreId dans le service, pas le comportement du repo. @@ -93,7 +94,8 @@ public class CampaignServiceTest { "New Campaign", "Description", null, - null + null, + 4 ); when(campaignRepository.save(any(Campaign.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -114,7 +116,8 @@ public class CampaignServiceTest { "New Campaign", "Description", " ", - null + null, + 4 ); when(campaignRepository.save(any(Campaign.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -179,7 +182,8 @@ public class CampaignServiceTest { "Updated Campaign", "Updated Description", "lore-456", - null + null, + 4 ); when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign)); when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); @@ -200,7 +204,8 @@ public class CampaignServiceTest { "Updated Campaign", "Updated Description", "lore-456", - null + null, + 4 ); when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty()); diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java index c7226c2..21f59c8 100644 --- a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java +++ b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java @@ -2,9 +2,11 @@ package com.loremind.infrastructure.transfer; import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.ProgressionStatus; import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.images.ports.ImageStorage; +import com.loremind.domain.playcontext.EntryType; import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.jpa.*; import com.loremind.infrastructure.transfer.dto.ContentExport; @@ -17,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -28,23 +31,19 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.when; /** - * Aller-retour EXPORT → ZIP → IMPORT (mode FUSION) du contenu portable. + * Aller-retour EXPORT → ZIP → IMPORT (mode FUSION) du contenu portable, JEU COMPRIS. *

- * On sème un graphe complet (système de jeu, lore/dossiers/template/page, campagne, - * arc, chapitres avec prérequis, scène avec branches/ennemis/battlemap, PNJ, ennemi, - * personnage, catalogue d'objets, table aléatoire, image + fichier), on l'exporte, + * On sème un graphe complet — prep (système, lore/dossiers/template/page, campagne, arc, + * chapitres avec prérequis, scène, PNJ, ennemi) ET espace de jeu (partie, séance, journal, + * flag, progression de quête, feuille de perso) — plus une image et un fichier. On l'exporte, * on le réimporte, puis on vérifie : *

- * Le stockage binaire (MinIO) est mocké : aucun appel réseau, et on contrôle ce que - * {@code download} renvoie pour les clés référencées. */ @SpringBootTest @Transactional @@ -69,6 +68,11 @@ class ContentExportImportRoundTripTest { @Autowired private RandomTableJpaRepository randomTableRepo; @Autowired private ImageJpaRepository imageRepo; @Autowired private StoredFileJpaRepository storedFileRepo; + @Autowired private PlaythroughJpaRepository playthroughRepo; + @Autowired private SessionJpaRepository sessionRepo; + @Autowired private SessionEntryJpaRepository sessionEntryRepo; + @Autowired private PlaythroughFlagJpaRepository playthroughFlagRepo; + @Autowired private QuestProgressionJpaRepository questProgressionRepo; @MockitoBean private ImageStorage imageStorage; @MockitoBean private FileStorage fileStorage; @@ -77,8 +81,8 @@ class ContentExportImportRoundTripTest { private static final String FILE_KEY = "files/round-trip-map.json"; @Test - void roundTrip_duplicatesEveryContentTypeAndRemapsReferences() throws IOException { - // ----- 1. Graphe de contenu riche ----- + void roundTrip_duplicatesEveryContentTypeIncludingPlayAndRemapsReferences() throws IOException { + // ----- 1. Prep ----- GameSystemJpaEntity gs = gameSystemRepo.save(GameSystemJpaEntity.builder() .name("RT System").description("d").foundryActorType("npc").isPublic(true).build()); @@ -89,18 +93,20 @@ class ContentExportImportRoundTripTest { .name("RT Child").loreId(lore.getId()).parentId(rootNode.getId()).order(1).build()); TemplateJpaEntity template = templateRepo.save(TemplateJpaEntity.builder() .loreId(lore.getId()).name("RT Template").defaultNodeId(rootNode.getId()).build()); - PageJpaEntity page = pageRepo.save(PageJpaEntity.builder() - .loreId(lore.getId()).nodeId(rootNode.getId()).templateId(template.getId()) - .title("RT Page").imageValues(Map.of("gallery", List.of(IMG_KEY))).build()); - // Auto-référence : vérifie le remap des relatedPageIds (2e passe). - page.setRelatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))); - pageRepo.save(page); - imageRepo.save(ImageJpaEntity.builder() + // L'image est référencée par son ID (convention de prod), pas par sa clé de stockage. + ImageJpaEntity image = imageRepo.save(ImageJpaEntity.builder() .filename("abc.png").contentType("image/png").sizeBytes(7).storageKey(IMG_KEY).build()); + String imageRef = String.valueOf(image.getId()); StoredFileJpaEntity storedFile = storedFileRepo.save(StoredFileJpaEntity.builder() .filename("map.json").contentType("application/json").sizeBytes(6).storageKey(FILE_KEY).build()); + PageJpaEntity page = pageRepo.save(PageJpaEntity.builder() + .loreId(lore.getId()).nodeId(rootNode.getId()).templateId(template.getId()) + .title("RT Page").imageValues(Map.of("gallery", List.of(imageRef))).build()); + page.setRelatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))); // auto-réf -> remap + pageRepo.save(page); + CampaignJpaEntity campaign = campaignRepo.save(CampaignJpaEntity.builder() .name("RT Campaign").description("d").arcsCount(1) .loreId(String.valueOf(lore.getId())).gameSystemId(String.valueOf(gs.getId())).build()); @@ -108,7 +114,7 @@ class ContentExportImportRoundTripTest { ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder() .name("RT Arc").campaignId(campaign.getId()).order(0).type(ArcType.HUB) .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))) - .illustrationImageIds(new ArrayList<>(List.of(IMG_KEY))).build()); + .illustrationImageIds(new ArrayList<>(List.of(imageRef))).build()); ChapterJpaEntity chapterA = chapterRepo.save(ChapterJpaEntity.builder() .name("RT Chapter A").arcId(arc.getId()).order(0).build()); @@ -127,17 +133,14 @@ class ContentExportImportRoundTripTest { .name("RT Scene").chapterId(chapterB.getId()).order(0) .enemyIds(new ArrayList<>(List.of(String.valueOf(enemy.getId())))) .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))) - .illustrationImageIds(new ArrayList<>(List.of(IMG_KEY))) + .illustrationImageIds(new ArrayList<>(List.of(imageRef))) .battlemapMediaFileId(String.valueOf(storedFile.getId())) .branches(new ArrayList<>(List.of(new SceneBranch("Si fuite", null, "cond")))).build()); npcRepo.save(NpcJpaEntity.builder() - .name("RT Npc").campaignId(campaign.getId()).portraitImageId(IMG_KEY).folder("Ville").order(0) + .name("RT Npc").campaignId(campaign.getId()).portraitImageId(imageRef).folder("Ville").order(0) .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))).build()); - characterRepo.save(CharacterJpaEntity.builder() - .name("RT Hero").campaignId(campaign.getId()).playthroughId(999L).order(0).build()); - ItemCatalogJpaEntity catalog = ItemCatalogJpaEntity.builder() .name("RT Catalog").campaignId(campaign.getId()).order(0).build(); CatalogItemJpaEntity item = CatalogItemJpaEntity.builder() @@ -152,77 +155,114 @@ class ContentExportImportRoundTripTest { table.setEntries(new ArrayList<>(List.of(entry))); randomTableRepo.save(table); - // Comptes AVANT export (incluent un éventuel seed) — l'export est GLOBAL. + // ----- 1b. Espace de jeu ----- + PlaythroughJpaEntity pt = playthroughRepo.save(PlaythroughJpaEntity.builder() + .campaignId(campaign.getId()).name("RT Partie").description("d").build()); + SessionJpaEntity session = sessionRepo.save(SessionJpaEntity.builder() + .name("RT Séance").campaignId(String.valueOf(campaign.getId())).playthroughId(pt.getId()) + .startedAt(LocalDateTime.of(2026, 1, 1, 20, 0)).build()); + sessionEntryRepo.save(SessionEntryJpaEntity.builder() + .sessionId(String.valueOf(session.getId())).type(EntryType.NOTE).content("Début") + .occurredAt(LocalDateTime.of(2026, 1, 1, 20, 5)).build()); + playthroughFlagRepo.save(PlaythroughFlagJpaEntity.builder() + .playthroughId(pt.getId()).name("porte_ouverte").value(true).build()); + questProgressionRepo.save(QuestProgressionJpaEntity.builder() + .playthroughId(pt.getId()).chapterId(chapterA.getId()).status(ProgressionStatus.IN_PROGRESS).build()); + characterRepo.save(CharacterJpaEntity.builder() + .name("RT Hero").campaignId(campaign.getId()).playthroughId(pt.getId()).order(0).build()); + + // Comptes AVANT export (l'export complet est GLOBAL → tout sera doublé). long campaignsBefore = campaignRepo.count(); long arcsBefore = arcRepo.count(); - long chaptersBefore = chapterRepo.count(); - long scenesBefore = sceneRepo.count(); - long npcsBefore = npcRepo.count(); - long enemiesBefore = enemyRepo.count(); long pagesBefore = pageRepo.count(); - long gsBefore = gameSystemRepo.count(); long imagesBefore = imageRepo.count(); + long playthroughsBefore = playthroughRepo.count(); + long sessionsBefore = sessionRepo.count(); + long entriesBefore = sessionEntryRepo.count(); + long flagsBefore = playthroughFlagRepo.count(); + long questsBefore = questProgressionRepo.count(); + long charactersBefore = characterRepo.count(); Long campaignId0 = campaign.getId(); Long pageId0 = page.getId(); + Long ptId0 = pt.getId(); + Long chapterAId0 = chapterA.getId(); when(imageStorage.download(IMG_KEY)).thenAnswer(inv -> new ByteArrayInputStream("PNGDATA".getBytes())); when(fileStorage.download(FILE_KEY)).thenAnswer(inv -> new ByteArrayInputStream("{\"x\":1}".getBytes())); - // ----- 2. Export logique + sérialisation ZIP ----- + // ----- 2. Export + ZIP ----- ContentExport export = exportService.buildExport("2026-01-02T03:04:05Z"); - assertEquals(1, export.manifest().formatVersion()); - assertEquals("2026-01-02T03:04:05Z", export.manifest().exportedAt()); - assertNotNull(export.manifest().appVersion()); - assertFalse(export.campaigns().isEmpty()); + assertEquals(2, export.manifest().formatVersion()); + assertEquals("complète", export.manifest().scope()); + assertFalse(export.playthroughs().isEmpty()); ByteArrayOutputStream baos = new ByteArrayOutputStream(); exportService.writeZip(export, baos); Map zip = readZip(baos.toByteArray()); assertTrue(zip.containsKey("manifest.json")); assertTrue(zip.containsKey("data.json")); - // Binaire image référencé (portrait/illustration) embarqué + fichier de battlemap. - assertTrue(zip.containsKey("images/" + IMG_KEY), "binaire image référencé attendu dans le zip"); + // Le binaire image référencé par ID est bien résolu vers sa clé puis embarqué. + assertTrue(zip.containsKey("images/" + IMG_KEY), "binaire image (réf par ID) attendu dans le zip"); assertTrue(zip.containsKey("files/" + FILE_KEY), "binaire fichier référencé attendu dans le zip"); - // ----- 3. Import (mode fusion : nouveaux ids) ----- + // ----- 3. Import (fusion) ----- ImportResult result = importService.importZip(new ByteArrayInputStream(baos.toByteArray())); - // Tout le contenu exporté est ré-inséré → chaque type est doublé. assertEquals(2 * campaignsBefore, campaignRepo.count()); assertEquals(2 * arcsBefore, arcRepo.count()); - assertEquals(2 * chaptersBefore, chapterRepo.count()); - assertEquals(2 * scenesBefore, sceneRepo.count()); - assertEquals(2 * npcsBefore, npcRepo.count()); - assertEquals(2 * enemiesBefore, enemyRepo.count()); assertEquals(2 * pagesBefore, pageRepo.count()); - assertEquals(2 * gsBefore, gameSystemRepo.count()); - // Clé image UNIQUE déjà présente → réutilisée, pas de doublon. - assertEquals(imagesBefore, imageRepo.count()); - assertTrue(result.imagesReused() >= 1, "l'image référencée doit être réutilisée"); - assertEquals((int) campaignsBefore, result.created().get("campaigns")); + assertEquals(imagesBefore, imageRepo.count()); // clé unique réutilisée + assertTrue(result.imagesReused() >= 1); + // Espace de jeu également dupliqué. + assertEquals(2 * playthroughsBefore, playthroughRepo.count()); + assertEquals(2 * sessionsBefore, sessionRepo.count()); + assertEquals(2 * entriesBefore, sessionEntryRepo.count()); + assertEquals(2 * flagsBefore, playthroughFlagRepo.count()); + assertEquals(2 * questsBefore, questProgressionRepo.count()); + assertEquals(2 * charactersBefore, characterRepo.count()); + assertEquals((int) playthroughsBefore, result.created().get("playthroughs")); - // ----- 4. Remapping des références (2e passe) ----- - // 4a. L'arc importé référence la PAGE importée (remappée), pas l'originale. - Long importedCampaignId = onlyOther(campaignRepo.findAll().stream() - .filter(c -> "RT Campaign".equals(c.getName())).map(CampaignJpaEntity::getId).toList(), campaignId0); - Long importedPageId = onlyOther(pageRepo.findAll().stream() - .filter(p -> "RT Page".equals(p.getTitle())).map(PageJpaEntity::getId).toList(), pageId0); + // ----- 4. Remapping ----- + Long importedCampaignId = onlyOther(idsByName(campaignRepo.findAll(), CampaignJpaEntity::getName, + "RT Campaign", CampaignJpaEntity::getId), campaignId0); + Long importedPageId = onlyOther(idsByName(pageRepo.findAll(), PageJpaEntity::getTitle, + "RT Page", PageJpaEntity::getId), pageId0); + Long importedPtId = onlyOther(idsByName(playthroughRepo.findAll(), PlaythroughJpaEntity::getName, + "RT Partie", PlaythroughJpaEntity::getId), ptId0); + Long importedChapterAId = onlyOther(idsByName(chapterRepo.findAll(), ChapterJpaEntity::getName, + "RT Chapter A", ChapterJpaEntity::getId), chapterAId0); + + // 4a. Prep : l'arc importé pointe la page importée (pas l'originale). ArcJpaEntity importedArc = arcRepo.findAll().stream() .filter(a -> "RT Arc".equals(a.getName()) && importedCampaignId.equals(a.getCampaignId())) .findFirst().orElseThrow(); - assertTrue(importedArc.getRelatedPageIds().contains(String.valueOf(importedPageId)), - "l'arc importé doit pointer la page importée (remap), pas l'originale"); + assertTrue(importedArc.getRelatedPageIds().contains(String.valueOf(importedPageId))); assertFalse(importedArc.getRelatedPageIds().contains(String.valueOf(pageId0))); - // 4b. Le personnage importé a son playthroughId remis à null (hors périmètre). - List heroes = characterRepo.findAll().stream() - .filter(c -> "RT Hero".equals(c.getName())).toList(); - assertEquals(2, heroes.size()); - assertTrue(heroes.stream().anyMatch(c -> c.getPlaythroughId() == null)); - assertTrue(heroes.stream().anyMatch(c -> Long.valueOf(999L).equals(c.getPlaythroughId()))); + // 4b. Jeu : la feuille de perso importée pointe la Partie importée (playthroughId préservé, pas null). + CharacterJpaEntity importedHero = characterRepo.findAll().stream() + .filter(c -> "RT Hero".equals(c.getName()) && importedPtId.equals(c.getPlaythroughId())) + .findFirst().orElseThrow(); + assertNotNull(importedHero); + + // 4c. Jeu : la séance importée pointe la Partie importée ; la progression importée le chapitre importé. + SessionJpaEntity importedSession = sessionRepo.findAll().stream() + .filter(s -> "RT Séance".equals(s.getName()) && importedPtId.equals(s.getPlaythroughId())) + .findFirst().orElseThrow(); + // La ref faible String campaignId de la séance est remappée vers la campagne importée. + assertEquals(String.valueOf(importedCampaignId), importedSession.getCampaignId()); + // L'entrée de journal importée pointe la séance importée (ref faible String sessionId). + SessionEntryJpaEntity importedEntry = sessionEntryRepo.findAll().stream() + .filter(e -> String.valueOf(importedSession.getId()).equals(e.getSessionId())) + .findFirst().orElseThrow(); + assertEquals("Début", importedEntry.getContent()); + + QuestProgressionJpaEntity importedQuest = questProgressionRepo.findAll().stream() + .filter(q -> importedPtId.equals(q.getPlaythroughId())) + .findFirst().orElseThrow(); + assertEquals(importedChapterAId, importedQuest.getChapterId()); } - /** Lit toutes les entrées (nom → octets) d'un zip en mémoire. */ private static Map readZip(byte[] bytes) throws IOException { Map out = new LinkedHashMap<>(); try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(bytes))) { @@ -239,7 +279,13 @@ class ContentExportImportRoundTripTest { return out; } - /** Renvoie l'unique id de la liste différent de {@code original} (l'élément importé). */ + /** Ids des entités dont le libellé == name. */ + private static List idsByName(List all, java.util.function.Function nameOf, + String name, java.util.function.Function idOf) { + return all.stream().filter(e -> name.equals(nameOf.apply(e))).map(idOf).toList(); + } + + /** L'unique id de la liste différent de {@code original} (l'élément importé). */ private static Long onlyOther(List ids, Long original) { return ids.stream().filter(id -> !id.equals(original)).findFirst().orElseThrow(); } diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java new file mode 100644 index 0000000..9e23900 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java @@ -0,0 +1,76 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.infrastructure.persistence.jpa.ArcJpaRepository; +import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Rétro-compatibilité : un export AU FORMAT v1 (avant l'ajout de l'espace de jeu et du + * champ {@code scope}) doit toujours s'importer. On forge le {@code data.json} v1 À LA MAIN + * (un {@code buildExport} actuel produirait du v2) : Manifest à 3 champs sans {@code scope}, + * AUCUNE des 5 listes de jeu, et une campagne SANS {@code playerCount}. Verrouille à la fois + * la désérialisation tolérante (Jackson : champs absents → null) et le {@code nullSafe} en + * bout de chaîne d'import. + */ +@SpringBootTest +@Transactional +class ContentExportV1ImportTest { + + @Autowired private ImportService importService; + @Autowired private CampaignJpaRepository campaignRepo; + @Autowired private ArcJpaRepository arcRepo; + + @Test + void importsLegacyV1ArchiveWithoutPlaySectionOrScope() throws Exception { + String dataJson = """ + { + "manifest": { "formatVersion": 1, "appVersion": "0.9.0", "exportedAt": "2025-01-01T00:00:00Z" }, + "campaigns": [ + { "id": 1, "name": "V1 Camp", "description": "ancienne", "arcsCount": 1, "loreId": null, "gameSystemId": null } + ], + "arcs": [ + { "id": 10, "name": "V1 Arc", "description": "d", "campaignId": 1, "order": 0, "type": "LINEAR", + "icon": null, "themes": null, "stakes": null, "gmNotes": null, "rewards": null, "resolution": null, + "relatedPageIds": [], "illustrationImageIds": [] } + ] + } + """; + byte[] zip = zipWithDataJson(dataJson); + + long campaignsBefore = campaignRepo.count(); + long arcsBefore = arcRepo.count(); + + ImportResult result = importService.importZip(new ByteArrayInputStream(zip)); + + // L'archive v1 s'importe sans exception ; son contenu est recréé et remappé. + assertEquals(campaignsBefore + 1, campaignRepo.count()); + assertEquals(arcsBefore + 1, arcRepo.count()); + assertEquals(1, result.created().get("campaigns")); + // Les 5 sections de jeu absentes → traitées comme vides (nullSafe), sans planter. + assertEquals(0, result.created().get("playthroughs")); + assertEquals(0, result.created().get("sessions")); + // La campagne v1 (sans playerCount → 0 par défaut) est bien présente. + assertTrue(campaignRepo.findAll().stream().anyMatch(c -> "V1 Camp".equals(c.getName()))); + } + + private static byte[] zipWithDataJson(String dataJson) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(baos)) { + zip.putNextEntry(new ZipEntry("data.json")); + zip.write(dataJson.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return baos.toByteArray(); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/SelectiveExportTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/SelectiveExportTest.java new file mode 100644 index 0000000..e5cf881 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/transfer/SelectiveExportTest.java @@ -0,0 +1,162 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.Room; +import com.loremind.infrastructure.persistence.entity.*; +import com.loremind.infrastructure.persistence.jpa.*; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Export SÉLECTIF par campagne ({@link ExportService#buildExport(String, ExportRequest)}) : + * la clôture ne contient QUE la campagne ciblée, et les options lore/jeu/images en règlent + * le périmètre. Vérifie l'isolation vis-à-vis des autres campagnes et le débrayage des + * sections optionnelles. + */ +@SpringBootTest +@Transactional +class SelectiveExportTest { + + @Autowired private ExportService exportService; + @Autowired private LoreJpaRepository loreRepo; + @Autowired private LoreNodeJpaRepository loreNodeRepo; + @Autowired private PageJpaRepository pageRepo; + @Autowired private CampaignJpaRepository campaignRepo; + @Autowired private ArcJpaRepository arcRepo; + @Autowired private ChapterJpaRepository chapterRepo; + @Autowired private SceneJpaRepository sceneRepo; + @Autowired private NpcJpaRepository npcRepo; + @Autowired private ImageJpaRepository imageRepo; + @Autowired private PlaythroughJpaRepository playthroughRepo; + @Autowired private CharacterJpaRepository characterRepo; + + private Long lastLoreId; + + @Test + void targetCampaign_scopesToCampaign_andExcludesPlayWhenOff() { + Long a = seedCampaign("Camp A", true, true); + seedCampaign("Camp B", true, true); // doit être absente de l'export de A + + ContentExport ex = exportService.buildExport("t", new ExportRequest(a, true, false, true)); + + assertEquals("Camp A", ex.manifest().scope()); + // Une seule campagne exportée : A. + assertEquals(List.of(a), ex.campaigns().stream().map(ContentExport.CampaignDto::id).toList()); + // Arcs/PNJ : uniquement ceux de A. + assertFalse(ex.arcs().isEmpty()); + assertTrue(ex.arcs().stream().allMatch(arc -> a.equals(arc.campaignId()))); + assertTrue(ex.npcs().stream().allMatch(n -> a.equals(n.campaignId()))); + // Jeu débrayé → ni parties ni feuilles de perso. + assertTrue(ex.playthroughs().isEmpty()); + assertTrue(ex.characters().isEmpty()); + // Lore inclus → lien conservé. + assertFalse(ex.lores().isEmpty()); + assertEquals(String.valueOf(lastLoreIdOf(a)), ex.campaigns().get(0).loreId()); + } + + @Test + void withPlayOn_includesTargetCampaignPlayOnly() { + Long a = seedCampaign("Camp A", false, true); + Long b = seedCampaign("Camp B", false, true); + + ContentExport ex = exportService.buildExport("t", new ExportRequest(a, true, true, true)); + + assertFalse(ex.playthroughs().isEmpty()); + assertTrue(ex.playthroughs().stream().allMatch(p -> a.equals(p.campaignId()))); + assertTrue(ex.playthroughs().stream().noneMatch(p -> b.equals(p.campaignId()))); + assertFalse(ex.characters().isEmpty()); + } + + @Test + void withoutLore_nullifiesLinkAndOmitsLore() { + Long a = seedCampaign("Camp A", true, false); + + ContentExport ex = exportService.buildExport("t", new ExportRequest(a, false, true, true)); + + assertTrue(ex.lores().isEmpty()); + assertTrue(ex.pages().isEmpty()); + // Lien lore neutralisé pour éviter une référence pendante. + assertNull(ex.campaigns().get(0).loreId()); + } + + @Test + void roomImages_areCollectedInClosure() { + // Image référencée UNIQUEMENT par une salle (Room) d'une scène (cas oublié initialement). + ImageJpaEntity img = imageRepo.save(ImageJpaEntity.builder() + .filename("plan.png").contentType("image/png").sizeBytes(5).storageKey("images/room-plan.png").build()); + CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder().name("Camp R").arcsCount(1).build()); + ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder() + .name("Arc").campaignId(camp.getId()).order(0).type(ArcType.LINEAR).build()); + ChapterJpaEntity chap = chapterRepo.save(ChapterJpaEntity.builder() + .name("Chap").arcId(arc.getId()).order(0).build()); + Room room = Room.builder().id("r1").name("Salle") + .illustrationImageIds(new ArrayList<>(List.of(String.valueOf(img.getId())))).build(); + sceneRepo.save(SceneJpaEntity.builder().name("Scene").chapterId(chap.getId()).order(0) + .rooms(new ArrayList<>(List.of(room))).build()); + + ContentExport ex = exportService.buildExport("t", new ExportRequest(camp.getId(), true, false, true)); + + assertTrue(ex.images().stream().anyMatch(i -> img.getId().equals(i.id())), + "l'image référencée par une room doit être collectée dans l'export ciblé"); + } + + @Test + void withoutImages_omitsImageMetadataAndBinaries() { + ImageJpaEntity img = imageRepo.save(ImageJpaEntity.builder() + .filename("i.png").contentType("image/png").sizeBytes(5).storageKey("images/no-img.png").build()); + CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder().name("Camp NI").arcsCount(1).build()); + arcRepo.save(ArcJpaEntity.builder().name("Arc").campaignId(camp.getId()).order(0).type(ArcType.LINEAR) + .illustrationImageIds(new ArrayList<>(List.of(String.valueOf(img.getId())))).build()); + + ContentExport ex = exportService.buildExport("t", new ExportRequest(camp.getId(), true, true, false)); + + assertTrue(ex.images().isEmpty(), "includeImages=false → aucune métadonnée d'image exportée"); + assertTrue(ex.storedFiles().isEmpty()); + } + + // --- Helpers de seed ------------------------------------------------------ + + /** Crée une campagne (+ arc + PNJ, + lore optionnel, + partie/perso optionnels) et renvoie son id. */ + private Long seedCampaign(String name, boolean withLore, boolean withPlay) { + String loreLink = null; + if (withLore) { + LoreJpaEntity lore = loreRepo.save(LoreJpaEntity.builder().name(name + " Lore").build()); + lastLoreId = lore.getId(); + LoreNodeJpaEntity node = loreNodeRepo.save(LoreNodeJpaEntity.builder() + .name("N").loreId(lore.getId()).order(0).build()); + pageRepo.save(PageJpaEntity.builder() + .loreId(lore.getId()).nodeId(node.getId()).title(name + " Page").build()); + loreLink = String.valueOf(lore.getId()); + } + CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder() + .name(name).loreId(loreLink).arcsCount(1).build()); + camp.setLoreId(loreLink); // au cas où le builder ignorerait null + campaignRepo.save(camp); + loreByCampaign.put(camp.getId(), lastLoreId); + arcRepo.save(ArcJpaEntity.builder() + .name(name + " Arc").campaignId(camp.getId()).order(0).type(ArcType.LINEAR).build()); + npcRepo.save(NpcJpaEntity.builder() + .name(name + " Npc").campaignId(camp.getId()).order(0).build()); + if (withPlay) { + PlaythroughJpaEntity pt = playthroughRepo.save(PlaythroughJpaEntity.builder() + .campaignId(camp.getId()).name(name + " PT").build()); + characterRepo.save(CharacterJpaEntity.builder() + .name(name + " Hero").campaignId(camp.getId()).playthroughId(pt.getId()).order(0).build()); + } + return camp.getId(); + } + + private final java.util.Map loreByCampaign = new java.util.HashMap<>(); + + private Long lastLoreIdOf(Long campaignId) { + return loreByCampaign.get(campaignId); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java new file mode 100644 index 0000000..8bf2051 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java @@ -0,0 +1,95 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.infrastructure.transfer.ExportRequest; +import com.loremind.infrastructure.transfer.ExportService; +import com.loremind.infrastructure.transfer.ImportService; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.NoSuchElementException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Test UNITAIRE (sans Spring) de {@link DataTransferController} : on vérifie que les + * paramètres de requête sont bien traduits en {@link ExportRequest} (sauvegarde complète + * vs ciblée, passage des trois drapeaux) et le mapping des codes HTTP (404 campagne + * inconnue, 403 mode démo). Pattern « controller instancié à la main + services mockés » + * (cf. CampaignImportControllerUnitTest). + */ +class DataTransferControllerTest { + + private final ExportService exportService = mock(ExportService.class); + private final ImportService importService = mock(ImportService.class); + private final DataTransferController controller = + new DataTransferController(exportService, importService, false); + + private static ContentExport emptyExport(String scope) { + return new ContentExport( + new ContentExport.Manifest(2, "dev", "t", scope), + List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of()); + } + + private ArgumentCaptor captureExportAfter(Runnable call) { + when(exportService.buildExport(anyString(), any(ExportRequest.class))) + .thenReturn(emptyExport("complète")); + call.run(); + ArgumentCaptor cap = ArgumentCaptor.forClass(ExportRequest.class); + verify(exportService).buildExport(anyString(), cap.capture()); + return cap; + } + + @Test + void export_noCampaignId_buildsFullRequest() { + ExportRequest req = captureExportAfter(() -> controller.export(null, true, true, true)).getValue(); + assertTrue(req.isFull()); + assertNull(req.campaignId()); + } + + @Test + void export_withParams_mapsCampaignAndAllFlags() { + ExportRequest req = captureExportAfter(() -> controller.export(5L, false, false, false)).getValue(); + assertFalse(req.isFull()); + assertEquals(5L, req.campaignId()); + assertFalse(req.includeLore()); + assertFalse(req.includePlay()); + assertFalse(req.includeImages()); + } + + @Test + void export_targetedWithDefaults_keepsFlagsTrue() { + ExportRequest req = captureExportAfter(() -> controller.export(7L, true, true, true)).getValue(); + assertEquals(7L, req.campaignId()); + assertTrue(req.includeLore()); + assertTrue(req.includePlay()); + assertTrue(req.includeImages()); + } + + @Test + void export_unknownCampaign_maps404() { + when(exportService.buildExport(anyString(), any(ExportRequest.class))) + .thenThrow(new NoSuchElementException("Campagne introuvable : 999")); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> controller.export(999L, true, true, true)); + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + + @Test + void export_demoMode_forbidden() { + DataTransferController demo = new DataTransferController(exportService, importService, true); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> demo.export(null, true, true, true)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } +} diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index d58d279..1750b55 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -5,7 +5,14 @@

{{ campaign.name }}

-

{{ campaign.description }}

+ @if (campaign.description) { +

{{ campaign.description }}

+ @if (campaign.description.length > 180) { + + } + }
{{ 'campaignDetail.players' | translate:{ n: campaign.playerCount || 0 } }} @@ -141,22 +148,26 @@
@if (npcs.length > 0) { @for (g of npcGroups; track g.folder) { - @if (g.folder) { -

{{ g.folder.split('/').join(' / ') }}

- } @else if (npcGroups.length > 1) { -

{{ 'campaignTree.unclassified' | translate }}

+ @if (g.folder || npcGroups.length > 1) { + } -
- @for (npc of g.items; track npc.id) { -
- -
- {{ npc.name }} - {{ personaSnippet(npc) }} + @if (!isNpcFolderCollapsed(g.folder)) { +
+ @for (npc of g.items; track npc.id) { +
+ +
+ {{ npc.name }} + {{ personaSnippet(npc) }} +
-
- } -
+ } +
+ } } } @if (npcs.length === 0) { @@ -222,15 +233,34 @@ @if (playthroughs.length > 0) {
@for (p of playthroughs; track p) { -
+
- {{ p.name }} - @if (p.description) { - {{ p.description }} + @if (editingPlaythroughId === p.id) { + + } @else { + {{ p.name }} + @if (p.description) { + {{ p.description }} + } }
+ @if (editingPlaythroughId === p.id) { + + + } @else { + + }
}
diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss index a8bd63c..b045b2d 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss @@ -38,6 +38,18 @@ flex-direction: column; gap: 0.15rem; min-width: 0; + flex: 1; +} + +.playthrough-rename-input { + width: 100%; + font-weight: 600; + font-size: 0.95rem; + padding: 0.15rem 0.4rem; + border: 1px solid var(--color-primary, #2c6cd6); + border-radius: 6px; + background: var(--color-surface, #fff); + color: inherit; } .playthrough-name { @@ -85,7 +97,29 @@ color: #6b7280; font-size: 0.95rem; line-height: 1.6; + margin-bottom: 0.4rem; + white-space: pre-wrap; + + // Replié : on borne à 3 lignes avec ellipse ; déplié = texte intégral. + &--clamped { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + } + + .desc-toggle { + background: none; + border: none; + padding: 0; margin-bottom: 1rem; + color: #8a7bc8; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + + &:hover { text-decoration: underline; } } .meta { @@ -508,12 +542,25 @@ // En-tête de dossier dans la grille des PNJ (regroupement par dossier). .persona-folder { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: #8a7bc8; margin: 0.9rem 0 0.4rem; + // En-tête de dossier cliquable (repli) : reset du } @else if (groups.length > 1) { -

{{ 'enemyList.unclassified' | translate }}

+ + } + @if (!isCollapsed(g.folder)) { +
+ @for (e of g.items; track e.id) { + + } +
} -
- @for (e of g.items; track e.id) { - - } -
}
diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss index d671e29..4862ae8 100644 --- a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss @@ -29,9 +29,15 @@ // En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes. .sbl-folder { display: flex; align-items: center; gap: 0.4rem; - margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600; + width: 100%; margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #a5b4fc; + // En-tête cliquable (repli) : on neutralise le style natif du } @else if (groups.length > 1) { -

{{ 'npcList.unclassified' | translate }}

+ + } + @if (!isCollapsed(g.folder)) { +
+ @for (n of g.items; track n.id) { + + } +
} -
- @for (n of g.items; track n.id) { - - } -
}
diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.scss b/web/src/app/campaigns/npc/npc-list/npc-list.component.scss index 501899e..fe97ebb 100644 --- a/web/src/app/campaigns/npc/npc-list/npc-list.component.scss +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.scss @@ -26,9 +26,15 @@ // En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes. .sbl-folder { display: flex; align-items: center; gap: 0.4rem; - margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600; + width: 100%; margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #a5b4fc; + // En-tête cliquable (repli) : on neutralise le style natif du + {{ characters.length }} + + @if (!pjCollapsed) { + @for (c of characters; track c) { + + } } } @if (npcs.length > 0) {
-

+

- @for (n of npcs; track n) { - + {{ npcs.length }} + + @if (!pnjCollapsed) { + @for (fg of npcGroups; track fg.folder) { +
+ + @if (!isNpcFolderCollapsed(fg.folder)) { + @for (n of fg.items; track n) { + + } + } +
+ } }
} @@ -147,25 +166,28 @@ {{ 'sessionReferencePanel.emptyScenes' | translate }}

} - @for (arc of treeData.arcs; track arc) { + @for (arc of sortedArcs; track arc) {
-

+

- @for (chapter of chaptersOf(arc); track chapter) { -
- {{ chapter.name }} - @for (scene of scenesOf(chapter); track scene) { - - } -
+ + @if (!isArcCollapsed(arc.id!)) { + @for (chapter of chaptersOf(arc); track chapter) { +
+ {{ chapter.name }} + @for (scene of scenesOf(chapter); track scene) { + + } +
+ } }
} diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.scss b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.scss index b3d18f4..3102d19 100644 --- a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.scss +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.scss @@ -12,21 +12,24 @@ // ─────────────── Tabs ─────────────── .ref-tabs { display: flex; + flex-wrap: wrap; // 6 onglets : on passe sur 2 rangs plutôt que de rogner « Scènes ». background: #111827; border-bottom: 1px solid #1f2937; } .ref-tab { - flex: 1; + flex: 1 1 33.333%; + min-width: 0; display: inline-flex; align-items: center; justify-content: center; - gap: 0.35rem; + gap: 0.3rem; background: transparent; color: #9ca3af; border: none; - padding: 0.7rem 0.5rem; - font-size: 0.8rem; + padding: 0.55rem 0.4rem; + font-size: 0.78rem; + white-space: nowrap; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; @@ -88,6 +91,37 @@ } } +// En-tête de section repliable (PJ / PNJ / arc), stylé comme un

mais cliquable. +.ref-group-head { + display: inline-flex; + align-items: center; + gap: 0.4rem; + width: 100%; + margin: 0 0 0.25rem 0; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: #9ca3af; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + text-align: left; + + &:hover { color: #d1d5db; } + + .ref-group-chevron { opacity: 0.6; flex-shrink: 0; } + + .ref-group-count { + margin-left: auto; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: #6b7280; + } +} + .ref-subgroup { display: flex; flex-direction: column; @@ -100,6 +134,27 @@ font-size: 0.72rem; margin-bottom: 0.15rem; } + + // En-tête de dossier de PNJ (repliable) dans l'onglet Personnages. + .ref-subgroup-head { + display: inline-flex; + align-items: center; + gap: 0.35rem; + width: 100%; + margin-bottom: 0.15rem; + padding: 0; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: #8a93a3; + font-size: 0.72rem; + + &:hover { color: #c0c7d1; } + + .ref-group-chevron { opacity: 0.55; flex-shrink: 0; } + .ref-group-count { margin-left: auto; color: #6b7280; } + } } .ref-item { diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts index 55b9d7f..fad6fd5 100644 --- a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts @@ -1,7 +1,7 @@ import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { TranslatePipe } from '@ngx-translate/core'; -import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular'; +import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package, ChevronDown, ChevronRight } from 'lucide-angular'; import { catchError, of } from 'rxjs'; import { CampaignService } from '../../services/campaign.service'; import { CharacterService } from '../../services/character.service'; @@ -11,6 +11,7 @@ import { Character } from '../../services/character.model'; import { Npc } from '../../services/npc.model'; import { Arc, Chapter, Scene } from '../../services/campaign.model'; import { loadCampaignTreeData, CampaignTreeData } from '../../campaigns/campaign-tree.helper'; +import { byOrder, groupByFolder, FolderGroup } from '../../shared/folder-grouping.util'; import { SessionDicePanelComponent, DiceRollResult } from '../session-dice-panel/session-dice-panel.component'; @@ -45,6 +46,8 @@ export class SessionReferencePanelComponent implements OnChanges { readonly Sparkles = Sparkles; readonly Table2 = Table2; readonly Package = Package; + readonly ChevronDown = ChevronDown; + readonly ChevronRight = ChevronRight; @Input() campaignId!: string; /** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */ @@ -63,6 +66,14 @@ export class SessionReferencePanelComponent implements OnChanges { npcs: Npc[] = []; treeData: CampaignTreeData | null = null; + /** Sections PJ / PNJ repliables (onglet Personnages). */ + pjCollapsed = false; + pnjCollapsed = false; + /** Dossiers de PNJ repliés (onglet Personnages) — par nom ('' = « Sans dossier »). */ + collapsedNpcFolders = new Set(); + /** Arcs repliés dans l'onglet Scènes — repliés par défaut pour une vue d'ensemble. */ + collapsedArcs = new Set(); + loadingChars = false; loadingTree = false; /** True dès qu'un tab "lourd" a été chargé pour éviter de rappeler l'API en boucle. */ @@ -120,11 +131,42 @@ export class SessionReferencePanelComponent implements OnChanges { catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData)) ).subscribe(data => { this.treeData = data; + // Arcs repliés par défaut : on présente une vue d'ensemble compacte, le MJ + // déplie l'arc qui l'intéresse. + this.collapsedArcs = new Set(data.arcs.map(a => a.id!).filter(Boolean) as string[]); this.loadingTree = false; this.treeLoaded = true; }); } + /** Arcs triés par ordre manuel (cohérent avec l'arbre et les cartes). */ + get sortedArcs(): Arc[] { + return [...(this.treeData?.arcs ?? [])].sort(byOrder); + } + + toggleArc(id: string): void { + if (this.collapsedArcs.has(id)) this.collapsedArcs.delete(id); + else this.collapsedArcs.add(id); + } + + isArcCollapsed(id: string): boolean { + return this.collapsedArcs.has(id); + } + + /** PNJ groupés par dossier (même classement que les vues cartes/sidebar). */ + get npcGroups(): FolderGroup[] { + return groupByFolder(this.npcs); + } + + toggleNpcFolder(folder: string): void { + if (this.collapsedNpcFolders.has(folder)) this.collapsedNpcFolders.delete(folder); + else this.collapsedNpcFolders.add(folder); + } + + isNpcFolderCollapsed(folder: string): boolean { + return this.collapsedNpcFolders.has(folder); + } + /** * Ouvre une fiche dans un nouvel onglet pour préserver l'écran de session. * Le MJ peut consulter sans perdre son journal ni son historique de dés. @@ -134,12 +176,12 @@ export class SessionReferencePanelComponent implements OnChanges { window.open('/' + url, '_blank', 'noopener'); } - /** Helpers de typage pour le template (Angular n'infère pas bien sans). */ + /** Chapitres/scènes triés par ordre manuel (l'arbre brut n'est pas ordonné). */ chaptersOf(arc: Arc): Chapter[] { - return this.treeData?.chaptersByArc[arc.id!] ?? []; + return [...(this.treeData?.chaptersByArc[arc.id!] ?? [])].sort(byOrder); } scenesOf(chapter: Chapter): Scene[] { - return this.treeData?.scenesByChapter[chapter.id!] ?? []; + return [...(this.treeData?.scenesByChapter[chapter.id!] ?? [])].sort(byOrder); } onDiceRolled(result: DiceRollResult): void { diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index ae6de6f..fce2864 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -376,6 +376,27 @@

{{ 'settings.data.title' | translate }}

+
+ + @if (exportScope !== 'full') { +
+ {{ 'settings.data.optionsLabel' | translate }} + + + +
+ } +