Compare commits
2 Commits
v1.0.2-bet
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| ad3ea0e9e6 | |||
| 0c44e42a22 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -129,3 +129,5 @@ web/coverage/
|
||||
brain/htmlcov/
|
||||
brain/.coverage
|
||||
foundry-module/
|
||||
plan-promotion-loremind.md
|
||||
post-reddit-foundryvtt.md
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
4
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "1.0.2-beta",
|
||||
"version": "1.0.3",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user