Compare commits

..

2 Commits

Author SHA1 Message Date
ad3ea0e9e6 CI : build web cassé par la sortie de npm 12 (npm@latest exige Node >= 22)
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 24s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 3m14s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Failing after 1m1s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 45s
E2E Tests / e2e (push) Failing after 4m6s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Successful in 1m12s
Build & Push Images / build (brain) (push) Successful in 2m14s
Build & Push Images / build (web) (push) Successful in 1m54s
Build & Push Images / build (core) (push) Successful in 3m41s
Build & Push Images / build-switcher (push) Successful in 48s
Suppression du « npm install -g npm@latest » du Dockerfile web : cible
mouvante inutile — le npm embarqué dans node:20 suffit pour npm ci
(lockfileVersion 3) et le build Angular.
2026-07-10 09:31:58 +02:00
0c44e42a22 Quêtes libres : fin des conteneurs fantômes (cascade, impact annoncé, export filtré, migration V24)
Some checks failed
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 52s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Successful in 2m36s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m11s
Build & Push Images / build (web) (push) Failing after 35s
Build & Push Images / build (brain) (push) Successful in 1m18s
Build & Push Images / build-switcher (push) Successful in 42s
Build & Push Images / build (core) (push) Successful in 3m8s
Sidebar Lore : le panneau Templates ne peut plus écraser l'arbre

Plafonné à 45 % de la sidebar avec ascenseur interne sur la liste —
l'en-tête, le repli et le bouton + restent toujours visibles, l'arbre
garde son propre scroll (remonté par un utilisateur avec ~30 templates)

Passage v1.0.3
2026-07-10 09:19:26 +02:00
18 changed files with 491 additions and 21 deletions

2
.gitignore vendored
View File

@@ -129,3 +129,5 @@ web/coverage/
brain/htmlcov/
brain/.coverage
foundry-module/
plan-promotion-loremind.md
post-reddit-foundryvtt.md

View File

@@ -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__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>1.0.2-beta</version>
<version>1.0.3</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

@@ -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.
*
* <p>Nettoyage du CONTENEUR (chapitre jumeau, jamais un chapitre simplement LIÉ —
* isContainerOf exclut les liens transversaux) :
* <ul>
* <li>jumeau de HUB non vide : GARDÉ — il redevient un chapitre visible de l'arc,
* aucune perte de contenu ;</li>
* <li>conteneur d'arc SYSTEM (quête libre) : supprimé AVEC ses scènes — une fois la
* quête partie il est invisible partout dans l'appli et pourrirait en fantôme
* (réapparitions dans les exports). L'impact est annoncé au préalable par
* {@link #getDeletionImpact} (dialogue de confirmation côté front) ;</li>
* <li>conteneur encore référencé par une autre quête : jamais touché.</li>
* </ul></p>
*
* <p>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<Quest> 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<Quest> 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<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
return nodes != null ? nodes : List.of();
}

View File

@@ -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<FoundryBundle.Quest> quests = new ArrayList<>();
List<FoundryBundle.Scene> scenes = new ArrayList<>();
Set<String> liveContainerIds = liveQuestContainerIds(campaign);
for (ArcJpaEntity arc : sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder)) {
List<ChapterJpaEntity> 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<String> liveQuestContainerIds(CampaignJpaEntity campaign) {
Set<String> ids = new HashSet<>();
for (QuestJpaEntity q : questRepo.findByCampaignId(campaign.getId())) {
for (QuestNodeRef n : q.getNodes() != null ? q.getNodes() : List.<QuestNodeRef>of()) {
if (n.nodeType() == NodeType.CHAPTER) ids.add(n.nodeId());
}
}
return ids;
}
/** PNJ : purement journal — hors perimetre sans les journaux. */
private List<FoundryBundle.Persona> buildNpcs(CampaignJpaEntity campaign, ExportOptions opts,
List<TemplateField> npcTemplate, AssetRegistry assets) {

View File

@@ -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<QuestService.DeletionImpact> 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<Void> reorder(@PathVariable String campaignId, @RequestBody ReorderRequest req) {

View File

@@ -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).
*
* <p>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 :</p>
* <ul>
* <li>conteneur orphelin AVEC scènes → une quête libre est RECRÉÉE dessus (même
* nom que le chapitre) : le contenu redevient visible sous « Quêtes », et
* l'utilisateur le garde ou le supprime via le flux normal (qui annonce
* l'impact) ;</li>
* <li>conteneur orphelin VIDE → supprimé (fantôme sans contenu).</li>
* </ul>
*
* <p>« 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":"<id>"} : 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).</p>
*
* <p>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.</p>
*/
@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<Candidate> 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();
}
}
}

View File

@@ -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");

View File

@@ -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));
}
}
}
}

View File

@@ -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());
}
}

View File

@@ -1,6 +1,8 @@
FROM node:20-bookworm-slim AS build
WORKDIR /build
RUN npm install -g npm@latest
# PAS de mise à jour de npm ici : « npm@latest » est une cible mouvante qui finit
# par exiger un Node plus récent que l'image (npm 12 -> Node >= 22) et casse le
# build. Le npm livré avec l'image suffit (npm ci supporte lockfileVersion 3).
COPY package*.json ./
RUN npm ci --include=dev --ignore-scripts --no-audit --no-fund --no-progress
COPY . .

4
web/package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "1.0.2-beta",
"version": "1.0.3",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -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 => {

View File

@@ -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<void>(`${this.base(campaignId)}/${questId}`);
}
deletionImpact(campaignId: string, questId: string): Observable<QuestDeletionImpact> {
return this.http.get<QuestDeletionImpact>(`${this.base(campaignId)}/${questId}/deletion-impact`);
}
reorder(campaignId: string, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.base(campaignId)}/reorder`, { orderedIds });
}

View File

@@ -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 {

View File

@@ -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)",

View File

@@ -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)",