diff --git a/.gitignore b/.gitignore index 936d052..48a1c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,5 @@ web/coverage/ brain/htmlcov/ brain/.coverage foundry-module/ +plan-promotion-loremind.md +post-reddit-foundryvtt.md diff --git a/brain/app/main.py b/brain/app/main.py index 75805c6..ebdb0c4 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="1.0.2-beta", + version="1.0.3", ) logger = logging.getLogger(__name__) diff --git a/core/pom.xml b/core/pom.xml index 30eaf1a..d02a981 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 1.0.2-beta + 1.0.3 LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java b/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java index 6d54aff..a0be75e 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java @@ -106,6 +106,11 @@ public class QuestService { /** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */ private boolean isContainerOf(Quest quest, Chapter chapter) { if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true; + return inSystemArc(chapter); + } + + /** Le chapitre vit-il dans l'arc technique SYSTEM (masqué partout dans l'appli) ? */ + private boolean inSystemArc(Chapter chapter) { return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId()) .map(a -> a.getType() == ArcType.SYSTEM) .orElse(false); @@ -156,6 +161,18 @@ public class QuestService { /** * Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties. * + *

Nettoyage du CONTENEUR (chapitre jumeau, jamais un chapitre simplement LIÉ — + * isContainerOf exclut les liens transversaux) : + *

+ * *

Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted} * d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni * nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais @@ -167,28 +184,47 @@ public class QuestService { progressionRepository.deleteByQuestId(id); questRepository.deleteById(id); if (quest == null) return; - // Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE - // (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître - // comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune - // perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du - // contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut. List remaining = questRepository.findByCampaignId(quest.getCampaignId()); for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) { if (node.nodeType() != NodeType.CHAPTER) continue; chapterRepository.findById(node.nodeId()).ifPresent(ch -> { boolean container = isContainerOf(quest, ch); - boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty(); boolean referencedElsewhere = remaining.stream() .anyMatch(q -> nullSafeNodes(q.getNodes()).stream() .anyMatch(n -> n.nodeType() == NodeType.CHAPTER && ch.getId().equals(n.nodeId()))); - if (container && empty && !referencedElsewhere) { - chapterRepository.deleteById(ch.getId()); - } + if (!container || referencedElsewhere) return; + var scenes = sceneRepository.findByChapterId(ch.getId()); + if (!scenes.isEmpty() && !inSystemArc(ch)) return; // jumeau de hub : reste visible + for (var scene : scenes) sceneRepository.deleteById(scene.getId()); + chapterRepository.deleteById(ch.getId()); }); } } + /** Scènes qui tomberont avec la quête (conteneurs d'arc SYSTEM exclusifs à cette quête). */ + public record DeletionImpact(int scenes) {} + + public DeletionImpact getDeletionImpact(String questId) { + Quest quest = questRepository.findById(questId).orElse(null); + if (quest == null) return new DeletionImpact(0); + List others = questRepository.findByCampaignId(quest.getCampaignId()).stream() + .filter(q -> !questId.equals(q.getId())) + .toList(); + int scenes = 0; + for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) { + if (node.nodeType() != NodeType.CHAPTER) continue; + Chapter ch = chapterRepository.findById(node.nodeId()).orElse(null); + if (ch == null || !isContainerOf(quest, ch) || !inSystemArc(ch)) continue; + boolean referencedElsewhere = others.stream() + .anyMatch(q -> nullSafeNodes(q.getNodes()).stream() + .anyMatch(n -> n.nodeType() == NodeType.CHAPTER + && ch.getId().equals(n.nodeId()))); + if (!referencedElsewhere) scenes += sceneRepository.findByChapterId(ch.getId()).size(); + } + return new DeletionImpact(scenes); + } + private static List nullSafeNodes(List nodes) { return nodes != null ? nodes : List.of(); } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java index 120ec2f..6875751 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java @@ -3,6 +3,9 @@ package com.loremind.infrastructure.transfer.foundry; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.loremind.domain.campaigncontext.quest.NodeType; +import com.loremind.domain.campaigncontext.quest.QuestNodeRef; +import com.loremind.domain.campaigncontext.structure.ArcType; import com.loremind.domain.campaigncontext.structure.Room; import com.loremind.domain.campaigncontext.structure.RoomBranch; import com.loremind.domain.campaigncontext.structure.SceneBattlemap; @@ -40,6 +43,7 @@ public class FoundryExportService { private final CampaignJpaRepository campaignRepo; private final ArcJpaRepository arcRepo; private final ChapterJpaRepository chapterRepo; + private final QuestJpaRepository questRepo; private final SceneJpaRepository sceneRepo; private final NpcJpaRepository npcRepo; private final EnemyJpaRepository enemyRepo; @@ -55,6 +59,7 @@ public class FoundryExportService { public FoundryExportService(CampaignJpaRepository campaignRepo, ArcJpaRepository arcRepo, ChapterJpaRepository chapterRepo, + QuestJpaRepository questRepo, SceneJpaRepository sceneRepo, NpcJpaRepository npcRepo, EnemyJpaRepository enemyRepo, @@ -69,6 +74,7 @@ public class FoundryExportService { this.campaignRepo = campaignRepo; this.arcRepo = arcRepo; this.chapterRepo = chapterRepo; + this.questRepo = questRepo; this.sceneRepo = sceneRepo; this.npcRepo = npcRepo; this.enemyRepo = enemyRepo; @@ -154,7 +160,20 @@ public class FoundryExportService { List quests = new ArrayList<>(); List scenes = new ArrayList<>(); + Set liveContainerIds = liveQuestContainerIds(campaign); for (ArcJpaEntity arc : sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder)) { + List chapters = sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder); + // L'arc technique SYSTEM (« Quetes libres ») est masque dans l'appli : seuls + // les conteneurs des quetes VIVANTES partent dans l'export. Un conteneur + // orphelin (quete supprimee, chapitre garde par deleteQuest car il contenait + // des scenes) est invisible cote LoreMind -> il ne doit pas reapparaitre + // cote Foundry. Arc omis entierement s'il ne reste rien. + if (arc.getType() == ArcType.SYSTEM) { + chapters = chapters.stream() + .filter(ch -> liveContainerIds.contains(str(ch.getId()))) + .toList(); + if (chapters.isEmpty()) continue; + } // Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) : // leurs illustrations ne sont pas embarquees. arcs.add(new FoundryBundle.Arc( @@ -163,7 +182,7 @@ public class FoundryExportService { arc.getThemes(), arc.getStakes(), arc.getGmNotes(), arc.getRewards(), arc.getResolution(), opts.journals() ? assets.images(arc.getIllustrationImageIds()) : List.of())); - for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) { + for (ChapterJpaEntity ch : chapters) { quests.add(new FoundryBundle.Quest( str(ch.getId()), str(arc.getId()), ch.getName(), ch.getDescription(), ch.getOrder(), ch.getIcon(), ch.getPlayerObjectives(), ch.getNarrativeStakes(), ch.getGmNotes(), @@ -177,6 +196,17 @@ public class FoundryExportService { return new ArcsQuestsScenes(arcs, quests, scenes); } + /** Ids des chapitres-conteneurs references par les quetes vivantes de la campagne. */ + private Set liveQuestContainerIds(CampaignJpaEntity campaign) { + Set ids = new HashSet<>(); + for (QuestJpaEntity q : questRepo.findByCampaignId(campaign.getId())) { + for (QuestNodeRef n : q.getNodes() != null ? q.getNodes() : List.of()) { + if (n.nodeType() == NodeType.CHAPTER) ids.add(n.nodeId()); + } + } + return ids; + } + /** PNJ : purement journal — hors perimetre sans les journaux. */ private List buildNpcs(CampaignJpaEntity campaign, ExportOptions opts, List npcTemplate, AssetRegistry assets) { diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java index a23227f..45ac5c3 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java @@ -86,6 +86,16 @@ public class QuestController { return ResponseEntity.noContent().build(); } + /** Impact d'une suppression : scènes du conteneur (quête libre) qui partiront avec. */ + @GetMapping("/{questId}/deletion-impact") + public ResponseEntity getDeletionImpact(@PathVariable String campaignId, + @PathVariable String questId) { + if (questService.getQuestById(questId).isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(questService.getDeletionImpact(questId)); + } + /** Réordonne les quêtes de la campagne : order = position. */ @PutMapping("/reorder") public ResponseEntity reorder(@PathVariable String campaignId, @RequestBody ReorderRequest req) { diff --git a/core/src/main/java/db/migration/V24__Reattach_orphan_free_quest_containers.java b/core/src/main/java/db/migration/V24__Reattach_orphan_free_quest_containers.java new file mode 100644 index 0000000..3902251 --- /dev/null +++ b/core/src/main/java/db/migration/V24__Reattach_orphan_free_quest_containers.java @@ -0,0 +1,118 @@ +package db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +/** + * Nettoyage des CONTENEURS ORPHELINS des quêtes libres (arc technique SYSTEM). + * + *

Jusqu'à la 1.0.2, supprimer une quête libre GARDAIT son chapitre-conteneur + * s'il contenait des scènes (garde anti-perte de contenu) — mais l'arc SYSTEM + * étant masqué partout dans l'appli, ce contenu devenait INVISIBLE et + * irrécupérable, tout en ressortant dans l'export Foundry (« quêtes fantômes »). + * {@code QuestService.deleteQuest} cascade désormais ces conteneurs ; cette + * migration répare l'existant SANS perte :

+ * + * + *

« Orphelin » = chapitre d'un arc SYSTEM dont l'id n'apparaît dans les + * {@code nodes} d'AUCUNE quête de la campagne. Détection prudente par motif + * {@code "nodeId":""} : une collision d'id avec un nœud SCENE fait considérer + * le chapitre comme référencé → il est laissé en place (aucun risque de perte).

+ * + *

Migration en Java (et non SQL) : la corrélation chapitre ↔ nodes JSON des + * quêtes demande une itération par candidat, illisible en SQL portable H2/PG.

+ */ +@SuppressWarnings("java:S101") // Nommage V24__... IMPOSE par la convention Flyway des migrations Java. +public class V24__Reattach_orphan_free_quest_containers extends BaseJavaMigration { + + private record Candidate(long chapterId, String name, long campaignId) {} + + @Override + public void migrate(Context context) throws Exception { + Connection conn = context.getConnection(); + + List candidates = new ArrayList<>(); + try (PreparedStatement ps = conn.prepareStatement( + "SELECT c.id, c.name, a.campaign_id FROM chapters c " + + "JOIN arcs a ON c.arc_id = a.id WHERE a.type = 'SYSTEM'"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + candidates.add(new Candidate(rs.getLong(1), rs.getString(2), rs.getLong(3))); + } + } + + for (Candidate ch : candidates) { + if (isReferencedByAQuest(conn, ch)) continue; // conteneur d'une quête vivante + if (countScenes(conn, ch.chapterId()) == 0) { + deleteEmptyChapter(conn, ch.chapterId()); + } else { + recreateFreeQuest(conn, ch); + } + } + } + + private boolean isReferencedByAQuest(Connection conn, Candidate ch) throws Exception { + try (PreparedStatement ps = conn.prepareStatement( + "SELECT COUNT(*) FROM quests WHERE campaign_id = ? AND nodes LIKE ?")) { + ps.setLong(1, ch.campaignId()); + ps.setString(2, "%\"nodeId\":\"" + ch.chapterId() + "\"%"); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getLong(1) > 0; + } + } + } + + private int countScenes(Connection conn, long chapterId) throws Exception { + try (PreparedStatement ps = conn.prepareStatement( + "SELECT COUNT(*) FROM scenes WHERE chapter_id = ?")) { + ps.setLong(1, chapterId); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getInt(1); + } + } + } + + private void deleteEmptyChapter(Connection conn, long chapterId) throws Exception { + try (PreparedStatement ps = conn.prepareStatement("DELETE FROM chapters WHERE id = ?")) { + ps.setLong(1, chapterId); + ps.executeUpdate(); + } + } + + /** Recrée une quête LIBRE (arc_id NULL) pointant le conteneur, à la suite de l'ordre. */ + private void recreateFreeQuest(Connection conn, Candidate ch) throws Exception { + int order; + try (PreparedStatement ps = conn.prepareStatement( + "SELECT COALESCE(MAX(\"order\"), -1) + 1 FROM quests WHERE campaign_id = ?")) { + ps.setLong(1, ch.campaignId()); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + order = rs.getInt(1); + } + } + try (PreparedStatement ps = conn.prepareStatement( + "INSERT INTO quests (campaign_id, \"order\", name, description, prerequisites, " + + "nodes, related_page_ids, illustration_image_ids, created_at, updated_at) " + + "VALUES (?, ?, ?, '', '[]', ?, '[]', '[]', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")) { + ps.setLong(1, ch.campaignId()); + ps.setInt(2, order); + ps.setString(3, ch.name()); + ps.setString(4, "[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"" + ch.chapterId() + "\",\"order\":0}]"); + ps.executeUpdate(); + } + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java index 4857928..d6bde94 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java @@ -230,13 +230,112 @@ class QuestServiceTest { .thenReturn(Optional.of(Chapter.builder().id("chap-real").arcId("arc-lin").name("Réel").build())); when(arcRepository.findById("arc-lin")) .thenReturn(Optional.of(Arc.builder().id("arc-lin").type(ArcType.LINEAR).build())); - when(sceneRepository.findByChapterId("chap-real")).thenReturn(List.of()); + // NB : pas de stub sceneRepository — un chapitre non-conteneur est écarté avant toute lecture des scènes. service.deleteQuest("q-1"); verify(chapterRepository, never()).deleteById(anyString()); } + @Test + void deleteQuest_freeQuest_cascadesScenesOfSystemContainer() { + // Conteneur d'arc SYSTEM : INVISIBLE une fois la quête supprimée -> il part avec + // ses scènes (sinon il pourrit en fantôme inaccessible qui ressort dans les exports). + Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of()); + when(chapterRepository.findById("chap-free")) + .thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build())); + when(arcRepository.findById("arc-sys")) + .thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build())); + when(sceneRepository.findByChapterId("chap-free")).thenReturn(List.of( + Scene.builder().id("s-1").chapterId("chap-free").name("S1").build(), + Scene.builder().id("s-2").chapterId("chap-free").name("S2").build())); + + service.deleteQuest("q-1"); + + verify(sceneRepository).deleteById("s-1"); + verify(sceneRepository).deleteById("s-2"); + verify(chapterRepository).deleteById("chap-free"); + } + + @Test + void deletionImpact_freeQuestWithScenes_countsThem() { + Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of(quest)); // filtrée par id + when(chapterRepository.findById("chap-free")) + .thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build())); + when(arcRepository.findById("arc-sys")) + .thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build())); + when(sceneRepository.findByChapterId("chap-free")).thenReturn(List.of( + Scene.builder().id("s-1").chapterId("chap-free").name("S1").build(), + Scene.builder().id("s-2").chapterId("chap-free").name("S2").build())); + + assertEquals(2, service.getDeletionImpact("q-1").scenes()); + } + + @Test + void deletionImpact_unknownQuest_returnsZero() { + when(questRepository.findById("inconnue")).thenReturn(Optional.empty()); + + assertEquals(0, service.getDeletionImpact("inconnue").scenes()); + } + + @Test + void deletionImpact_systemContainerReferencedByAnotherQuest_reportsZero() { + // Conteneur PARTAGÉ (autre quête le référence) : il survivra -> impact 0. + Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build(); + Quest other = Quest.builder().id("q-2").campaignId("camp").name("Autre") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of(quest, other)); + when(chapterRepository.findById("chap-free")) + .thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build())); + when(arcRepository.findById("arc-sys")) + .thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build())); + + assertEquals(0, service.getDeletionImpact("q-1").scenes()); + } + + @Test + void deleteQuest_freeQuest_systemContainerReferencedElsewhere_keptWithScenes() { + // Même partagé en arc SYSTEM, un conteneur encore référencé n'est JAMAIS cascadé. + Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build(); + Quest other = Quest.builder().id("q-2").campaignId("camp").name("Autre") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of(other)); + when(chapterRepository.findById("chap-free")) + .thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build())); + when(arcRepository.findById("arc-sys")) + .thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build())); + + service.deleteQuest("q-1"); + + verify(chapterRepository, never()).deleteById(anyString()); + verify(sceneRepository, never()).deleteById(anyString()); + } + + @Test + void deletionImpact_hubQuest_reportsZero() { + // Jumeau de hub : GARDÉ à la suppression (il reste visible dans l'arc) -> impact 0. + Quest quest = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Q") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of(quest)); + when(chapterRepository.findById("chap-1")) + .thenReturn(Optional.of(Chapter.builder().id("chap-1").arcId("arc-h").name("Q").build())); + when(arcRepository.findById("arc-h")) + .thenReturn(Optional.of(Arc.builder().id("arc-h").type(ArcType.HUB).build())); + + assertEquals(0, service.getDeletionImpact("q-1").scenes()); + } + @Test void deleteQuest_keepsContainerReferencedByAnotherQuest() { Quest quest = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Q") @@ -247,7 +346,7 @@ class QuestServiceTest { when(questRepository.findByCampaignId("camp")).thenReturn(List.of(other)); when(chapterRepository.findById("chap-1")) .thenReturn(Optional.of(Chapter.builder().id("chap-1").arcId("arc-h").name("Q").build())); - when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of()); + // NB : pas de stub sceneRepository — un conteneur encore référencé est écarté avant toute lecture. service.deleteQuest("q-1"); diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java index b6edea3..b8d16c9 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java @@ -12,6 +12,8 @@ import java.sql.SQLException; import java.sql.Statement; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -119,4 +121,77 @@ class FlywayMigrationTest { assertEquals("[]", rs.getString(2)); } } + + /** + * V24 : les conteneurs ORPHELINS de l'arc SYSTEM (quête libre supprimée avant que + * la suppression ne cascade) sont réparés — rattachés à une quête recréée s'ils + * ont des scènes (contenu redevenu visible), supprimés s'ils sont vides. Le + * conteneur d'une quête vivante n'est pas touché. + */ + @Test + void v24_reattachesOrphanSystemContainers_andDropsEmptyOnes() throws SQLException { + String url = "jdbc:h2:mem:flyway_v24_test;MODE=PostgreSQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1"; + + // 1) Schéma arrêté AVANT V24… + Flyway.configure() + .dataSource(url, "sa", "") + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("23")) + .load() + .migrate(); + + // 2) …peuplé : un arc SYSTEM avec un orphelin PLEIN (1 scène), un orphelin + // VIDE, et le conteneur d'une quête VIVANTE. + try (Connection conn = DriverManager.getConnection(url, "sa", ""); + Statement st = conn.createStatement()) { + st.executeUpdate("insert into campaigns (id, name, arcs_count, created_at, updated_at) " + + "values (1, 'C', 0, now(), now())"); + st.executeUpdate("insert into arcs (id, name, campaign_id, \"order\", type, created_at, updated_at) " + + "values (9, 'Quêtes libres', 1, 9999, 'SYSTEM', now(), now())"); + st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) " + + "values (41, 'Orphelin plein', 9, 0, now(), now())"); + st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) " + + "values (42, 'Orphelin vide', 9, 1, now(), now())"); + st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) " + + "values (43, 'Vivant', 9, 2, now(), now())"); + st.executeUpdate("insert into scenes (id, name, chapter_id, \"order\", created_at, updated_at) " + + "values (1, 'S', 41, 0, now(), now())"); + st.executeUpdate("insert into quests (campaign_id, \"order\", name, nodes, created_at, updated_at) " + + "values (1, 0, 'Vivant', '[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"43\",\"order\":0}]', now(), now())"); + } + + // 3) Fin de la chaîne : V24 répare. + Flyway.configure() + .dataSource(url, "sa", "") + .locations("classpath:db/migration") + .load() + .migrate(); + + try (Connection conn = DriverManager.getConnection(url, "sa", ""); + Statement st = conn.createStatement()) { + + // L'orphelin PLEIN est rattaché : quête LIBRE (arc_id NULL) recréée dessus. + try (ResultSet rs = st.executeQuery( + "select arc_id, nodes from quests where name = 'Orphelin plein'")) { + assertTrue(rs.next(), "une quête aurait dû être recréée sur le conteneur orphelin"); + assertNull(rs.getObject(1)); + assertEquals("[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"41\",\"order\":0}]", rs.getString(2)); + } + + // L'orphelin VIDE a disparu ; l'orphelin plein et le conteneur vivant restent. + try (ResultSet rs = st.executeQuery("select id from chapters where arc_id = 9 order by id")) { + assertTrue(rs.next()); + assertEquals(41, rs.getInt(1)); + assertTrue(rs.next()); + assertEquals(43, rs.getInt(1)); + assertFalse(rs.next(), "l'orphelin vide (42) aurait dû être supprimé"); + } + + // Pas de doublon : la quête vivante n'a pas été re-rattachée. + try (ResultSet rs = st.executeQuery("select count(*) from quests")) { + rs.next(); + assertEquals(2, rs.getInt(1)); + } + } + } } diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java index 8890b39..17fe9d1 100644 --- a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java +++ b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java @@ -1,5 +1,8 @@ package com.loremind.infrastructure.transfer.foundry; +import com.loremind.domain.campaigncontext.quest.NodeType; +import com.loremind.domain.campaigncontext.quest.QuestNodeRef; +import com.loremind.domain.campaigncontext.structure.ArcType; import com.loremind.domain.campaigncontext.structure.SceneBattlemap; import com.loremind.domain.shared.template.TemplateField; import com.loremind.infrastructure.persistence.entity.*; @@ -30,6 +33,7 @@ class FoundryExportServiceTest { @Autowired private CampaignJpaRepository campaignRepo; @Autowired private ArcJpaRepository arcRepo; @Autowired private ChapterJpaRepository chapterRepo; + @Autowired private QuestJpaRepository questRepo; @Autowired private SceneJpaRepository sceneRepo; @Autowired private NpcJpaRepository npcRepo; @Autowired private GameSystemJpaRepository gameSystemRepo; @@ -251,4 +255,60 @@ class FoundryExportServiceTest { assertThrows(java.util.NoSuchElementException.class, () -> service.buildBundle("999999999", "2026-06-25T00:00:00Z")); } + + /** + * L'arc technique SYSTEM heberge les conteneurs des quetes libres. Un conteneur + * ORPHELIN (quete supprimee, chapitre garde par deleteQuest car il contenait des + * scenes) est invisible dans l'appli : il ne doit pas partir dans le bundle. + */ + @Test + void buildBundle_systemArc_exportsOnlyLiveQuestContainers() { + CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder() + .name("Quetes libres").description("d").arcsCount(1).build()); + ArcJpaEntity system = arcRepo.save(ArcJpaEntity.builder() + .campaignId(camp.getId()).name("Quêtes libres").order(9999) + .type(ArcType.SYSTEM).build()); + ChapterJpaEntity live = chapterRepo.save(ChapterJpaEntity.builder() + .arcId(system.getId()).name("Quete vivante").order(0).build()); + ChapterJpaEntity orphan = chapterRepo.save(ChapterJpaEntity.builder() + .arcId(system.getId()).name("test de quete").order(1).build()); + sceneRepo.save(SceneJpaEntity.builder() + .chapterId(live.getId()).name("Scene vivante").order(0).build()); + sceneRepo.save(SceneJpaEntity.builder() + .chapterId(orphan.getId()).name("Scene fantome").order(0).build()); + questRepo.save(QuestJpaEntity.builder() + .campaignId(camp.getId()).name("Quete vivante").order(0) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, String.valueOf(live.getId()), 0))) + .build()); + + FoundryBundle.Data data = service.buildBundle( + String.valueOf(camp.getId()), "2026-07-09T00:00:00Z").data(); + + assertEquals(1, data.arcs().size()); + assertEquals(1, data.quests().size()); + assertEquals("Quete vivante", data.quests().get(0).name()); + assertEquals(1, data.scenes().size()); + assertEquals("Scene vivante", data.scenes().get(0).name()); + } + + /** Sans quete vivante, l'arc SYSTEM disparait entierement du bundle. */ + @Test + void buildBundle_systemArcWithoutLiveQuest_isDroppedEntirely() { + CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder() + .name("Fantomes").description("d").arcsCount(1).build()); + ArcJpaEntity system = arcRepo.save(ArcJpaEntity.builder() + .campaignId(camp.getId()).name("Quêtes libres").order(9999) + .type(ArcType.SYSTEM).build()); + ChapterJpaEntity orphan = chapterRepo.save(ChapterJpaEntity.builder() + .arcId(system.getId()).name("test").order(0).build()); + sceneRepo.save(SceneJpaEntity.builder() + .chapterId(orphan.getId()).name("test").order(0).build()); + + FoundryBundle.Data data = service.buildBundle( + String.valueOf(camp.getId()), "2026-07-09T00:00:00Z").data(); + + assertTrue(data.arcs().isEmpty()); + assertTrue(data.quests().isEmpty()); + assertTrue(data.scenes().isEmpty()); + } } diff --git a/web/package-lock.json b/web/package-lock.json index 145e00b..a02887a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "1.0.2-beta", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "1.0.2-beta", + "version": "1.0.3", "dependencies": { "@angular/animations": "^21.2.17", "@angular/cdk": "^21.2.14", diff --git a/web/package.json b/web/package.json index 59a3e1b..54f849c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "1.0.2-beta", + "version": "1.0.3", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/campaigns/quest/quest-view/quest-view.component.ts b/web/src/app/campaigns/quest/quest-view/quest-view.component.ts index 48582c8..66d9ed9 100644 --- a/web/src/app/campaigns/quest/quest-view/quest-view.component.ts +++ b/web/src/app/campaigns/quest/quest-view/quest-view.component.ts @@ -181,14 +181,32 @@ export class QuestViewComponent implements OnInit { this.router.navigate(['/campaigns', this.campaignId, 'quests', this.questId, 'edit']); } - /** Suppression simple : une quête n'a pas d'enfants (les nœuds sont des références faibles). */ + /** + * Suppression avec annonce de l'impact réel : le conteneur de scènes d'une quête + * LIBRE (arc SYSTEM, invisible dans l'arbre) part en cascade avec ses scènes — + * le nombre est affiché dans la confirmation. Les nœuds simplement liés et les + * jumeaux de hub (qui restent visibles dans leur arc) ne sont pas concernés. + */ deleteQuest(): void { - if (!this.quest) return; const quest = this.quest; + if (!quest?.id) return; + this.questService.deletionImpact(this.campaignId, quest.id).subscribe({ + next: impact => this.confirmDeleteQuest(quest, impact.scenes), + error: () => this.confirmDeleteQuest(quest, 0) // impact indisponible : confirmation simple + }); + } + + private confirmDeleteQuest(quest: Quest, scenes: number): void { + const details = [ + ...(scenes > 0 + ? [this.translate.instant('questView.deleteImpactScenes', { count: scenes })] + : []), + this.translate.instant('questView.irreversible') + ]; this.confirmDialog.confirm({ title: this.translate.instant('questView.deleteTitle'), message: this.translate.instant('questView.deleteMessage', { name: quest.name }), - details: [this.translate.instant('questView.irreversible')], + details, confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { diff --git a/web/src/app/services/quest.service.ts b/web/src/app/services/quest.service.ts index 6ea0d82..7d36202 100644 --- a/web/src/app/services/quest.service.ts +++ b/web/src/app/services/quest.service.ts @@ -3,6 +3,11 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Quest, QuestCreate } from './campaign.model'; +/** Impact d'une suppression : scènes du conteneur (quête libre) qui partiront avec. */ +export interface QuestDeletionImpact { + scenes: number; +} + /** * Service HTTP pour les Quêtes (Niveau 1). API nestée sous la campagne : * /api/campaigns/{campaignId}/quests. Si {@code playthroughId} est fourni, les @@ -39,6 +44,10 @@ export class QuestService { return this.http.delete(`${this.base(campaignId)}/${questId}`); } + deletionImpact(campaignId: string, questId: string): Observable { + return this.http.get(`${this.base(campaignId)}/${questId}/deletion-impact`); + } + reorder(campaignId: string, orderedIds: string[]): Observable { return this.http.put(`${this.base(campaignId)}/reorder`, { orderedIds }); } diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss index 74a7eca..116145f 100644 --- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss +++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss @@ -148,6 +148,11 @@ display: flex; flex-direction: column; gap: 0.25rem; + // PLAFONNÉ : avec beaucoup de templates, c'est la liste du panneau qui défile + // (cf. .panel-list) — sans plafond, le panneau grandissait jusqu'à écraser + // complètement l'arbre au-dessus (sidebar en overflow: hidden). + flex-shrink: 0; + max-height: 45%; } .panel-header-row { @@ -202,6 +207,10 @@ display: flex; flex-direction: column; gap: 0.1rem; + // Ascenseur interne, symétrique de celui de l'arbre (.tree) : l'en-tête du + // panneau et son bouton « + » restent toujours visibles. + overflow-y: auto; + min-height: 0; } .panel-item { diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index ca86951..8756656 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -1707,6 +1707,7 @@ "notProvided": "Not provided", "deleteTitle": "Delete quest", "deleteMessage": "Delete the quest \"{{name}}\"?", + "deleteImpactScenes": "{{count}} scene(s) of this quest will be deleted with it.", "irreversible": "This action is irreversible.", "deletedPage": "(deleted page)", "deletedQuest": "(deleted quest)", diff --git a/web/src/assets/i18n/fr.json b/web/src/assets/i18n/fr.json index 41f8960..3aa1c9a 100644 --- a/web/src/assets/i18n/fr.json +++ b/web/src/assets/i18n/fr.json @@ -1707,6 +1707,7 @@ "notProvided": "Non renseigné", "deleteTitle": "Supprimer la quête", "deleteMessage": "Supprimer la quête « {{name}} » ?", + "deleteImpactScenes": "{{count}} scène(s) de cette quête seront supprimées avec elle.", "irreversible": "Cette action est irréversible.", "deletedPage": "(page supprimée)", "deletedQuest": "(quête supprimée)",