Intégration du graphe et du multi-branche pour la partie campagne
This commit is contained in:
@@ -179,7 +179,9 @@ class ChatUseCase:
|
||||
return (
|
||||
"--- CAMPAGNE COURANTE ---\n"
|
||||
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n"
|
||||
f"Structure narrative :\n{arcs_block}"
|
||||
"Structure narrative (les flèches → indiquent des transitions de scène "
|
||||
"déclenchées par un choix des joueurs) :\n"
|
||||
f"{arcs_block}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -212,6 +214,11 @@ class ChatUseCase:
|
||||
block.append(f" - {scene.name} (scène){sc_hint}")
|
||||
if scene.description:
|
||||
block.append(f" Description : {scene.description}")
|
||||
for br in scene.branches:
|
||||
cond = f" (si : {br.condition})" if br.condition else ""
|
||||
block.append(
|
||||
f' → "{br.label}" vers {br.target_scene_name}{cond}'
|
||||
)
|
||||
return block
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -4,7 +4,7 @@ On utilise @dataclass (pas Pydantic) pour garder le domaine exempt de toute
|
||||
dépendance framework. Pydantic apparaît uniquement aux frontières : DTOs HTTP
|
||||
dans `main.py`, Settings dans `core/config.py`.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -109,15 +109,30 @@ class PageContext:
|
||||
values: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneBranchHint:
|
||||
"""Indice d'une branche narrative vers une autre scène du même chapitre.
|
||||
|
||||
Le Core Java résout déjà `targetSceneId` en nom humain avant l'envoi :
|
||||
l'IA ne voit donc jamais d'UUID, seulement des noms qu'elle peut citer.
|
||||
"""
|
||||
|
||||
label: str
|
||||
target_scene_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneSummary:
|
||||
"""Résumé d'une scène : nom + description courte + nb illustrations."""
|
||||
"""Résumé d'une scène : nom + description courte + illustrations + branches."""
|
||||
|
||||
name: str
|
||||
description: str | None
|
||||
# Depuis l'etape 6 : permet a l'IA de savoir qu'une scene a des illustrations
|
||||
# attachees. 0 par defaut pour retrocompat si le Core n'envoie rien.
|
||||
illustration_count: int = 0
|
||||
# Connexions narratives sortantes (livre dont vous etes le heros).
|
||||
branches: list[SceneBranchHint] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.domain.models import (
|
||||
PageContext,
|
||||
PageGenerationContext,
|
||||
PageSummary,
|
||||
SceneBranchHint,
|
||||
SceneSummary,
|
||||
)
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
@@ -105,6 +106,14 @@ class PageContextDTO(BaseModel):
|
||||
values: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SceneBranchHintDTO(BaseModel):
|
||||
"""Indice d'une branche narrative (le Core a deja resolu le nom cible)."""
|
||||
|
||||
label: str
|
||||
target_scene_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class SceneSummaryDTO(BaseModel):
|
||||
"""Résumé d'une scène : nom + description courte (synopsis)."""
|
||||
|
||||
@@ -113,6 +122,8 @@ class SceneSummaryDTO(BaseModel):
|
||||
# Optionnel : le Core Java ne serialise illustration_count QUE si > 0
|
||||
# (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent.
|
||||
illustration_count: int = 0
|
||||
# Branches narratives sortantes, omises cote Core si vides.
|
||||
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChapterSummaryDTO(BaseModel):
|
||||
@@ -357,6 +368,14 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
|
||||
name=sc.name,
|
||||
description=sc.description,
|
||||
illustration_count=sc.illustration_count,
|
||||
branches=[
|
||||
SceneBranchHint(
|
||||
label=br.label,
|
||||
target_scene_name=br.target_scene_name,
|
||||
condition=br.condition,
|
||||
)
|
||||
for br in sc.branches
|
||||
],
|
||||
)
|
||||
for sc in ch.scenes
|
||||
],
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Scene.
|
||||
@@ -66,6 +69,11 @@ public class SceneService {
|
||||
scene.setEnemies(updated.getEnemies());
|
||||
scene.setRelatedPageIds(updated.getRelatedPageIds());
|
||||
scene.setIllustrationImageIds(updated.getIllustrationImageIds());
|
||||
scene.setBranches(updated.getBranches());
|
||||
|
||||
// Validation métier : le graphe narratif doit rester cohérent.
|
||||
validateBranches(scene);
|
||||
|
||||
return sceneRepository.save(scene);
|
||||
}
|
||||
|
||||
@@ -76,4 +84,38 @@ public class SceneService {
|
||||
public boolean sceneExists(String id) {
|
||||
return sceneRepository.existsById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie les invariants du graphe narratif :
|
||||
* 1. Pas d'auto-référence (scène qui pointe sur elle-même).
|
||||
* 2. Toutes les branches pointent vers des scènes du MÊME chapitre.
|
||||
* 3. Pas de targetSceneId null/vide.
|
||||
*
|
||||
* Note : on ne vérifie PAS l'existence réelle de chaque scène cible
|
||||
* individuellement (ça serait un N+1). On charge une seule fois les
|
||||
* IDs du chapitre et on compare.
|
||||
*/
|
||||
private void validateBranches(Scene scene) {
|
||||
List<SceneBranch> branches = scene.getBranches();
|
||||
if (branches == null || branches.isEmpty()) return;
|
||||
|
||||
// IDs des scènes du chapitre courant (référentiel de validation)
|
||||
Set<String> chapterSceneIds = sceneRepository.findByChapterId(scene.getChapterId()).stream()
|
||||
.map(Scene::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
for (SceneBranch b : branches) {
|
||||
String target = b.getTargetSceneId();
|
||||
if (target == null || target.isBlank()) {
|
||||
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
|
||||
}
|
||||
if (target.equals(scene.getId())) {
|
||||
throw new IllegalArgumentException("Une scène ne peut pas se brancher sur elle-même");
|
||||
}
|
||||
if (!chapterSceneIds.contains(target)) {
|
||||
throw new IllegalArgumentException(
|
||||
"La branche pointe vers la scène " + target + " qui n'appartient pas au même chapitre");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -84,23 +86,44 @@ public class CampaignStructuralContextBuilder {
|
||||
}
|
||||
|
||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||
List<SceneSummary> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
|
||||
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
|
||||
.sorted(Comparator.comparingInt(Scene::getOrder))
|
||||
.map(this::toSceneSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Map id -> nom construite en une seule passe pour resoudre les
|
||||
// targetSceneId des branches sans re-interroger le repo (evite N+1).
|
||||
Map<String, String> nameById = scenes.stream()
|
||||
.collect(Collectors.toMap(Scene::getId, Scene::getName));
|
||||
|
||||
List<SceneSummary> summaries = scenes.stream()
|
||||
.map(s -> toSceneSummary(s, nameById))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ChapterSummary.builder()
|
||||
.name(chapter.getName())
|
||||
.description(chapter.getDescription())
|
||||
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
|
||||
.scenes(scenes)
|
||||
.scenes(summaries)
|
||||
.build();
|
||||
}
|
||||
|
||||
private SceneSummary toSceneSummary(Scene scene) {
|
||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
||||
List<BranchHint> hints = scene.getBranches() == null
|
||||
? List.of()
|
||||
: scene.getBranches().stream()
|
||||
.map(b -> BranchHint.builder()
|
||||
.label(b.getLabel())
|
||||
.targetSceneName(nameById.getOrDefault(
|
||||
b.getTargetSceneId(), "(scène inconnue)"))
|
||||
.condition(b.getCondition())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return SceneSummary.builder()
|
||||
.name(scene.getName())
|
||||
.description(scene.getDescription())
|
||||
.illustrationCount(countImages(scene.getIllustrationImageIds()))
|
||||
.branches(hints)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ public class Scene {
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre).
|
||||
* Chaque branche décrit un choix des joueurs et la scène de destination.
|
||||
* Liste vide = scène "feuille" (fin de chapitre ou scène linéaire).
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<SceneBranch> branches = new ArrayList<>();
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -71,4 +79,21 @@ public class Scene {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public void addBranch(SceneBranch branch) {
|
||||
if (branch == null) return;
|
||||
if (branches == null) branches = new ArrayList<>();
|
||||
// Interdit l'auto-référence (scène qui pointe sur elle-même)
|
||||
if (this.id != null && this.id.equals(branch.getTargetSceneId())) {
|
||||
throw new IllegalArgumentException("Une scène ne peut pas se brancher sur elle-même");
|
||||
}
|
||||
branches.add(branch);
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void removeBranchTo(String targetSceneId) {
|
||||
if (branches == null || targetSceneId == null) return;
|
||||
boolean removed = branches.removeIf(b -> targetSceneId.equals(b.getTargetSceneId()));
|
||||
if (removed) this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
/**
|
||||
* Value Object représentant une "sortie" narrative depuis une Scene.
|
||||
* Décrit un choix offert aux joueurs et la scène de destination associée.
|
||||
*
|
||||
* Immuable (@Value) : pour "modifier" une branche on la remplace.
|
||||
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
|
||||
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
|
||||
*
|
||||
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
|
||||
* (validation portée par SceneService).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
@Jacksonized
|
||||
public class SceneBranch {
|
||||
|
||||
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
|
||||
/** Id de la Scene de destination, intra-chapitre uniquement. */
|
||||
String targetSceneId;
|
||||
|
||||
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
|
||||
String condition;
|
||||
}
|
||||
@@ -52,12 +52,25 @@ public class CampaignStructuralContext {
|
||||
@Singular List<SceneSummary> scenes;
|
||||
}
|
||||
|
||||
/** Résumé d'une scène : nom + description courte. */
|
||||
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class SceneSummary {
|
||||
String name;
|
||||
String description;
|
||||
int illustrationCount;
|
||||
@Singular List<BranchHint> branches;
|
||||
}
|
||||
|
||||
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class BranchHint {
|
||||
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
|
||||
String targetSceneName;
|
||||
/** Condition MJ privée (optionnel). */
|
||||
String condition;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
@@ -253,6 +254,23 @@ public class BrainAiChatClient implements AiChatProvider {
|
||||
if (s.getIllustrationCount() > 0) {
|
||||
map.put("illustration_count", s.getIllustrationCount());
|
||||
}
|
||||
// Branches narratives : serialise uniquement si presentes, pour garder
|
||||
// un payload leger sur les scenes lineaires classiques.
|
||||
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
|
||||
map.put("branches", s.getBranches().stream()
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", b.getLabel());
|
||||
map.put("target_scene_name", b.getTargetSceneName());
|
||||
if (b.getCondition() != null && !b.getCondition().isBlank()) {
|
||||
map.put("condition", b.getCondition());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Convertit une List<SceneBranch> du domaine en chaîne JSON stockée en base,
|
||||
* et inversement. Même pattern que StringListJsonConverter mais typé sur
|
||||
* le Value Object SceneBranch.
|
||||
*
|
||||
* Adaptateur d'infrastructure : le domaine reste pur (List<SceneBranch>)
|
||||
* pendant que PostgreSQL reçoit un TEXT JSON.
|
||||
*/
|
||||
@Converter
|
||||
public class SceneBranchListJsonConverter implements AttributeConverter<List<SceneBranch>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(List<SceneBranch> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) {
|
||||
return "[]";
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur sérialisation List<SceneBranch> → JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SceneBranch> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<List<SceneBranch>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur désérialisation JSON → List<SceneBranch>", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -78,6 +80,13 @@ public class SceneJpaEntity {
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
// Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
|
||||
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).
|
||||
@Column(name = "branches", columnDefinition = "TEXT")
|
||||
@Convert(converter = SceneBranchListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<SceneBranch> branches = new ArrayList<>();
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
||||
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||
: new ArrayList<>())
|
||||
.branches(jpaEntity.getBranches() != null
|
||||
? new ArrayList<>(jpaEntity.getBranches())
|
||||
: new ArrayList<>())
|
||||
.createdAt(jpaEntity.getCreatedAt())
|
||||
.updatedAt(jpaEntity.getUpdatedAt())
|
||||
.build();
|
||||
@@ -112,6 +115,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
||||
.illustrationImageIds(scene.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(scene.getIllustrationImageIds())
|
||||
: new ArrayList<>())
|
||||
.branches(scene.getBranches() != null
|
||||
? new ArrayList<>(scene.getBranches())
|
||||
: new ArrayList<>())
|
||||
.createdAt(scene.getCreatedAt())
|
||||
.updatedAt(scene.getUpdatedAt())
|
||||
.build();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.loremind.infrastructure.web.dto.campaigncontext;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO pour une branche narrative (sortie) depuis une Scene.
|
||||
* Pendant web du Value Object domaine SceneBranch.
|
||||
*
|
||||
* @Data (mutable) est approprié pour les DTO de wire : Jackson désérialise
|
||||
* via setters côté requête entrante.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SceneBranchDTO {
|
||||
|
||||
private String label;
|
||||
private String targetSceneId;
|
||||
private String condition;
|
||||
}
|
||||
@@ -32,4 +32,7 @@ public class SceneDTO {
|
||||
|
||||
/** IDs des images (Shared Kernel) illustrant cette scene. */
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
/** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */
|
||||
private List<SceneBranchDTO> branches = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.loremind.infrastructure.web.mapper;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.SceneBranchDTO;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.SceneDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Mapper pour convertir entre Scene (entité de domaine) et SceneDTO.
|
||||
@@ -37,6 +41,7 @@ public class SceneMapper {
|
||||
dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(scene.getIllustrationImageIds())
|
||||
: new ArrayList<>());
|
||||
dto.setBranches(toBranchDTOs(scene.getBranches()));
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -65,6 +70,27 @@ public class SceneMapper {
|
||||
.illustrationImageIds(dto.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(dto.getIllustrationImageIds())
|
||||
: new ArrayList<>())
|
||||
.branches(toBranchDomain(dto.getBranches()))
|
||||
.build();
|
||||
}
|
||||
|
||||
// ─────────────── Mapping des branches (VO <-> DTO) ───────────────
|
||||
|
||||
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
|
||||
if (branches == null) return new ArrayList<>();
|
||||
return branches.stream()
|
||||
.map(b -> new SceneBranchDTO(b.getLabel(), b.getTargetSceneId(), b.getCondition()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
|
||||
if (dtos == null) return new ArrayList<>();
|
||||
return dtos.stream()
|
||||
.map(d -> SceneBranch.builder()
|
||||
.label(d.getLabel())
|
||||
.targetSceneId(d.getTargetSceneId())
|
||||
.condition(d.getCondition())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Test unitaire pour ArcService.
|
||||
* Utilise des mocks pour les ports de sortie (ArcRepository).
|
||||
* Teste la logique d'orchestration de la couche Application.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class ArcServiceTest {
|
||||
|
||||
@Mock
|
||||
private ArcRepository arcRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ArcService arcService;
|
||||
|
||||
private Arc testArc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testArc = Arc.builder()
|
||||
.id("arc-1")
|
||||
.name("Test Arc")
|
||||
.description("Test Description")
|
||||
.campaignId("campaign-1")
|
||||
.order(1)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateArc() {
|
||||
// Arrange
|
||||
when(arcRepository.save(any(Arc.class))).thenReturn(testArc);
|
||||
|
||||
// Act
|
||||
Arc result = arcService.createArc("New Arc", "Description", "campaign-1", 1);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(arcRepository, times(1)).save(any(Arc.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetArcById_Found() {
|
||||
// Arrange
|
||||
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(testArc));
|
||||
|
||||
// Act
|
||||
Optional<Arc> result = arcService.getArcById("arc-1");
|
||||
|
||||
// Assert
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals("Test Arc", result.get().getName());
|
||||
verify(arcRepository, times(1)).findById("arc-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetArcById_NotFound() {
|
||||
// Arrange
|
||||
when(arcRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
||||
|
||||
// Act
|
||||
Optional<Arc> result = arcService.getArcById("invalid-id");
|
||||
|
||||
// Assert
|
||||
assertFalse(result.isPresent());
|
||||
verify(arcRepository, times(1)).findById("invalid-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAllArcs() {
|
||||
// Arrange
|
||||
Arc arc2 = Arc.builder()
|
||||
.id("arc-2")
|
||||
.name("Arc 2")
|
||||
.build();
|
||||
when(arcRepository.findAll()).thenReturn(List.of(testArc, arc2));
|
||||
|
||||
// Act
|
||||
List<Arc> result = arcService.getAllArcs();
|
||||
|
||||
// Assert
|
||||
assertEquals(2, result.size());
|
||||
verify(arcRepository, times(1)).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetArcsByCampaignId() {
|
||||
// Arrange
|
||||
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(testArc));
|
||||
|
||||
// Act
|
||||
List<Arc> result = arcService.getArcsByCampaignId("campaign-1");
|
||||
|
||||
// Assert
|
||||
assertEquals(1, result.size());
|
||||
verify(arcRepository, times(1)).findByCampaignId("campaign-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateArc_Success() {
|
||||
// Arrange
|
||||
Arc updatedArc = Arc.builder()
|
||||
.name("Updated Arc")
|
||||
.description("Updated Description")
|
||||
.order(2)
|
||||
.themes("Theme1, Theme2")
|
||||
.stakes("High stakes")
|
||||
.gmNotes("GM notes")
|
||||
.rewards("Rewards")
|
||||
.resolution("Resolution")
|
||||
.relatedPageIds(List.of("page-1"))
|
||||
.illustrationImageIds(List.of("image-1"))
|
||||
.build();
|
||||
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(testArc));
|
||||
when(arcRepository.save(any(Arc.class))).thenReturn(testArc);
|
||||
|
||||
// Act
|
||||
Arc result = arcService.updateArc("arc-1", updatedArc);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(arcRepository, times(1)).findById("arc-1");
|
||||
verify(arcRepository, times(1)).save(any(Arc.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateArc_NotFound() {
|
||||
// Arrange
|
||||
Arc updatedArc = Arc.builder()
|
||||
.name("Updated Arc")
|
||||
.build();
|
||||
when(arcRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
||||
|
||||
// Act & Assert
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> arcService.updateArc("invalid-id", updatedArc)
|
||||
);
|
||||
assertEquals("Arc non trouvé avec l'ID: invalid-id", exception.getMessage());
|
||||
verify(arcRepository, times(1)).findById("invalid-id");
|
||||
verify(arcRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteArc() {
|
||||
// Arrange
|
||||
doNothing().when(arcRepository).deleteById("arc-1");
|
||||
|
||||
// Act
|
||||
arcService.deleteArc("arc-1");
|
||||
|
||||
// Assert
|
||||
verify(arcRepository, times(1)).deleteById("arc-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testArcExists_True() {
|
||||
// Arrange
|
||||
when(arcRepository.existsById("arc-1")).thenReturn(true);
|
||||
|
||||
// Act
|
||||
boolean result = arcService.arcExists("arc-1");
|
||||
|
||||
// Assert
|
||||
assertTrue(result);
|
||||
verify(arcRepository, times(1)).existsById("arc-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testArcExists_False() {
|
||||
// Arrange
|
||||
when(arcRepository.existsById("invalid-id")).thenReturn(false);
|
||||
|
||||
// Act
|
||||
boolean result = arcService.arcExists("invalid-id");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
verify(arcRepository, times(1)).existsById("invalid-id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Test unitaire pour CampaignService.
|
||||
* Utilise des mocks pour les ports de sortie (CampaignRepository).
|
||||
* Teste la logique d'orchestration de la couche Application.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class CampaignServiceTest {
|
||||
|
||||
@Mock
|
||||
private CampaignRepository campaignRepository;
|
||||
|
||||
@InjectMocks
|
||||
private CampaignService campaignService;
|
||||
|
||||
private Campaign testCampaign;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testCampaign = Campaign.builder()
|
||||
.id("campaign-1")
|
||||
.name("Test Campaign")
|
||||
.description("Test Description")
|
||||
.loreId("lore-1")
|
||||
.arcsCount(0)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateCampaign_WithLoreId() {
|
||||
// Arrange
|
||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||
"New Campaign",
|
||||
"Description",
|
||||
"lore-123"
|
||||
);
|
||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
||||
|
||||
// Act
|
||||
Campaign result = campaignService.createCampaign(data);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(campaignRepository, times(1)).save(any(Campaign.class));
|
||||
assertEquals("lore-123", result.getLoreId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateCampaign_WithNullLoreId() {
|
||||
// Arrange
|
||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||
"New Campaign",
|
||||
"Description",
|
||||
null
|
||||
);
|
||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
||||
|
||||
// Act
|
||||
Campaign result = campaignService.createCampaign(data);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(campaignRepository, times(1)).save(any(Campaign.class));
|
||||
assertNull(result.getLoreId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateCampaign_WithBlankLoreId() {
|
||||
// Arrange
|
||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||
"New Campaign",
|
||||
"Description",
|
||||
" "
|
||||
);
|
||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
||||
|
||||
// Act
|
||||
Campaign result = campaignService.createCampaign(data);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(campaignRepository, times(1)).save(any(Campaign.class));
|
||||
assertNull(result.getLoreId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCampaignById_Found() {
|
||||
// Arrange
|
||||
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
|
||||
|
||||
// Act
|
||||
Optional<Campaign> result = campaignService.getCampaignById("campaign-1");
|
||||
|
||||
// Assert
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals("Test Campaign", result.get().getName());
|
||||
verify(campaignRepository, times(1)).findById("campaign-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCampaignById_NotFound() {
|
||||
// Arrange
|
||||
when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
||||
|
||||
// Act
|
||||
Optional<Campaign> result = campaignService.getCampaignById("invalid-id");
|
||||
|
||||
// Assert
|
||||
assertFalse(result.isPresent());
|
||||
verify(campaignRepository, times(1)).findById("invalid-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAllCampaigns() {
|
||||
// Arrange
|
||||
Campaign campaign2 = Campaign.builder()
|
||||
.id("campaign-2")
|
||||
.name("Campaign 2")
|
||||
.build();
|
||||
when(campaignRepository.findAll()).thenReturn(List.of(testCampaign, campaign2));
|
||||
|
||||
// Act
|
||||
List<Campaign> result = campaignService.getAllCampaigns();
|
||||
|
||||
// Assert
|
||||
assertEquals(2, result.size());
|
||||
verify(campaignRepository, times(1)).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateCampaign_Success() {
|
||||
// Arrange
|
||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||
"Updated Campaign",
|
||||
"Updated Description",
|
||||
"lore-456"
|
||||
);
|
||||
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
|
||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
||||
|
||||
// Act
|
||||
Campaign result = campaignService.updateCampaign("campaign-1", data);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(campaignRepository, times(1)).findById("campaign-1");
|
||||
verify(campaignRepository, times(1)).save(any(Campaign.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateCampaign_NotFound() {
|
||||
// Arrange
|
||||
CampaignService.CampaignData data = new CampaignService.CampaignData(
|
||||
"Updated Campaign",
|
||||
"Updated Description",
|
||||
"lore-456"
|
||||
);
|
||||
when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
||||
|
||||
// Act & Assert
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> campaignService.updateCampaign("invalid-id", data)
|
||||
);
|
||||
assertEquals("Campaign non trouvé avec l'ID: invalid-id", exception.getMessage());
|
||||
verify(campaignRepository, times(1)).findById("invalid-id");
|
||||
verify(campaignRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteCampaign() {
|
||||
// Arrange
|
||||
doNothing().when(campaignRepository).deleteById("campaign-1");
|
||||
|
||||
// Act
|
||||
campaignService.deleteCampaign("campaign-1");
|
||||
|
||||
// Assert
|
||||
verify(campaignRepository, times(1)).deleteById("campaign-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCampaignExists_True() {
|
||||
// Arrange
|
||||
when(campaignRepository.existsById("campaign-1")).thenReturn(true);
|
||||
|
||||
// Act
|
||||
boolean result = campaignService.campaignExists("campaign-1");
|
||||
|
||||
// Assert
|
||||
assertTrue(result);
|
||||
verify(campaignRepository, times(1)).existsById("campaign-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCampaignExists_False() {
|
||||
// Arrange
|
||||
when(campaignRepository.existsById("invalid-id")).thenReturn(false);
|
||||
|
||||
// Act
|
||||
boolean result = campaignService.campaignExists("invalid-id");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
verify(campaignRepository, times(1)).existsById("invalid-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearchCampaigns_WithValidQuery() {
|
||||
// Arrange
|
||||
when(campaignRepository.searchByName("Test")).thenReturn(List.of(testCampaign));
|
||||
|
||||
// Act
|
||||
List<Campaign> result = campaignService.searchCampaigns("Test");
|
||||
|
||||
// Assert
|
||||
assertEquals(1, result.size());
|
||||
verify(campaignRepository, times(1)).searchByName("Test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearchCampaigns_WithNullQuery() {
|
||||
// Act
|
||||
List<Campaign> result = campaignService.searchCampaigns(null);
|
||||
|
||||
// Assert
|
||||
assertTrue(result.isEmpty());
|
||||
verify(campaignRepository, never()).searchByName(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearchCampaigns_WithBlankQuery() {
|
||||
// Act
|
||||
List<Campaign> result = campaignService.searchCampaigns(" ");
|
||||
|
||||
// Assert
|
||||
assertTrue(result.isEmpty());
|
||||
verify(campaignRepository, never()).searchByName(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearchCampaigns_WithTrim() {
|
||||
// Arrange
|
||||
when(campaignRepository.searchByName("Test")).thenReturn(List.of(testCampaign));
|
||||
|
||||
// Act
|
||||
List<Campaign> result = campaignService.searchCampaigns(" Test ");
|
||||
|
||||
// Assert
|
||||
assertEquals(1, result.size());
|
||||
verify(campaignRepository, times(1)).searchByName("Test");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Test unitaire pour ChapterService.
|
||||
* Utilise des mocks pour les ports de sortie (ChapterRepository).
|
||||
* Teste la logique d'orchestration de la couche Application.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class ChapterServiceTest {
|
||||
|
||||
@Mock
|
||||
private ChapterRepository chapterRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ChapterService chapterService;
|
||||
|
||||
private Chapter testChapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testChapter = Chapter.builder()
|
||||
.id("chapter-1")
|
||||
.name("Test Chapter")
|
||||
.description("Test Description")
|
||||
.arcId("arc-1")
|
||||
.order(1)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateChapter() {
|
||||
// Arrange
|
||||
when(chapterRepository.save(any(Chapter.class))).thenReturn(testChapter);
|
||||
|
||||
// Act
|
||||
Chapter result = chapterService.createChapter("New Chapter", "Description", "arc-1", 1);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(chapterRepository, times(1)).save(any(Chapter.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetChapterById_Found() {
|
||||
// Arrange
|
||||
when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(testChapter));
|
||||
|
||||
// Act
|
||||
Optional<Chapter> result = chapterService.getChapterById("chapter-1");
|
||||
|
||||
// Assert
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals("Test Chapter", result.get().getName());
|
||||
verify(chapterRepository, times(1)).findById("chapter-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetChapterById_NotFound() {
|
||||
// Arrange
|
||||
when(chapterRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
||||
|
||||
// Act
|
||||
Optional<Chapter> result = chapterService.getChapterById("invalid-id");
|
||||
|
||||
// Assert
|
||||
assertFalse(result.isPresent());
|
||||
verify(chapterRepository, times(1)).findById("invalid-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAllChapters() {
|
||||
// Arrange
|
||||
Chapter chapter2 = Chapter.builder()
|
||||
.id("chapter-2")
|
||||
.name("Chapter 2")
|
||||
.build();
|
||||
when(chapterRepository.findAll()).thenReturn(List.of(testChapter, chapter2));
|
||||
|
||||
// Act
|
||||
List<Chapter> result = chapterService.getAllChapters();
|
||||
|
||||
// Assert
|
||||
assertEquals(2, result.size());
|
||||
verify(chapterRepository, times(1)).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetChaptersByArcId() {
|
||||
// Arrange
|
||||
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(testChapter));
|
||||
|
||||
// Act
|
||||
List<Chapter> result = chapterService.getChaptersByArcId("arc-1");
|
||||
|
||||
// Assert
|
||||
assertEquals(1, result.size());
|
||||
verify(chapterRepository, times(1)).findByArcId("arc-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateChapter_Success() {
|
||||
// Arrange
|
||||
Chapter updatedChapter = Chapter.builder()
|
||||
.name("Updated Chapter")
|
||||
.description("Updated Description")
|
||||
.order(2)
|
||||
.gmNotes("GM notes")
|
||||
.playerObjectives("Objectives")
|
||||
.narrativeStakes("Stakes")
|
||||
.relatedPageIds(List.of("page-1"))
|
||||
.illustrationImageIds(List.of("image-1"))
|
||||
.build();
|
||||
when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(testChapter));
|
||||
when(chapterRepository.save(any(Chapter.class))).thenReturn(testChapter);
|
||||
|
||||
// Act
|
||||
Chapter result = chapterService.updateChapter("chapter-1", updatedChapter);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(chapterRepository, times(1)).findById("chapter-1");
|
||||
verify(chapterRepository, times(1)).save(any(Chapter.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateChapter_NotFound() {
|
||||
// Arrange
|
||||
Chapter updatedChapter = Chapter.builder()
|
||||
.name("Updated Chapter")
|
||||
.build();
|
||||
when(chapterRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
||||
|
||||
// Act & Assert
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> chapterService.updateChapter("invalid-id", updatedChapter)
|
||||
);
|
||||
assertEquals("Chapter non trouvé avec l'ID: invalid-id", exception.getMessage());
|
||||
verify(chapterRepository, times(1)).findById("invalid-id");
|
||||
verify(chapterRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteChapter() {
|
||||
// Arrange
|
||||
doNothing().when(chapterRepository).deleteById("chapter-1");
|
||||
|
||||
// Act
|
||||
chapterService.deleteChapter("chapter-1");
|
||||
|
||||
// Assert
|
||||
verify(chapterRepository, times(1)).deleteById("chapter-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testChapterExists_True() {
|
||||
// Arrange
|
||||
when(chapterRepository.existsById("chapter-1")).thenReturn(true);
|
||||
|
||||
// Act
|
||||
boolean result = chapterService.chapterExists("chapter-1");
|
||||
|
||||
// Assert
|
||||
assertTrue(result);
|
||||
verify(chapterRepository, times(1)).existsById("chapter-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testChapterExists_False() {
|
||||
// Arrange
|
||||
when(chapterRepository.existsById("invalid-id")).thenReturn(false);
|
||||
|
||||
// Act
|
||||
boolean result = chapterService.chapterExists("invalid-id");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
verify(chapterRepository, times(1)).existsById("invalid-id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Test unitaire pour SceneService.
|
||||
* Utilise des mocks pour les ports de sortie (SceneRepository).
|
||||
* Teste la logique d'orchestration de la couche Application.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class SceneServiceTest {
|
||||
|
||||
@Mock
|
||||
private SceneRepository sceneRepository;
|
||||
|
||||
@InjectMocks
|
||||
private SceneService sceneService;
|
||||
|
||||
private Scene testScene;
|
||||
private Scene scene2;
|
||||
private Scene scene3;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testScene = Scene.builder()
|
||||
.id("scene-1")
|
||||
.name("Test Scene")
|
||||
.description("Test Description")
|
||||
.chapterId("chapter-1")
|
||||
.order(1)
|
||||
.build();
|
||||
|
||||
scene2 = Scene.builder()
|
||||
.id("scene-2")
|
||||
.name("Scene 2")
|
||||
.chapterId("chapter-1")
|
||||
.order(2)
|
||||
.build();
|
||||
|
||||
scene3 = Scene.builder()
|
||||
.id("scene-3")
|
||||
.name("Scene 3")
|
||||
.chapterId("chapter-1")
|
||||
.order(3)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateScene() {
|
||||
// Arrange
|
||||
when(sceneRepository.save(any(Scene.class))).thenReturn(testScene);
|
||||
|
||||
// Act
|
||||
Scene result = sceneService.createScene("New Scene", "Description", "chapter-1", 1);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(sceneRepository, times(1)).save(any(Scene.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSceneById_Found() {
|
||||
// Arrange
|
||||
when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene));
|
||||
|
||||
// Act
|
||||
Optional<Scene> result = sceneService.getSceneById("scene-1");
|
||||
|
||||
// Assert
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals("Test Scene", result.get().getName());
|
||||
verify(sceneRepository, times(1)).findById("scene-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSceneById_NotFound() {
|
||||
// Arrange
|
||||
when(sceneRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
||||
|
||||
// Act
|
||||
Optional<Scene> result = sceneService.getSceneById("invalid-id");
|
||||
|
||||
// Assert
|
||||
assertFalse(result.isPresent());
|
||||
verify(sceneRepository, times(1)).findById("invalid-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAllScenes() {
|
||||
// Arrange
|
||||
when(sceneRepository.findAll()).thenReturn(List.of(testScene, scene2));
|
||||
|
||||
// Act
|
||||
List<Scene> result = sceneService.getAllScenes();
|
||||
|
||||
// Assert
|
||||
assertEquals(2, result.size());
|
||||
verify(sceneRepository, times(1)).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetScenesByChapterId() {
|
||||
// Arrange
|
||||
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(testScene, scene2));
|
||||
|
||||
// Act
|
||||
List<Scene> result = sceneService.getScenesByChapterId("chapter-1");
|
||||
|
||||
// Assert
|
||||
assertEquals(2, result.size());
|
||||
verify(sceneRepository, times(1)).findByChapterId("chapter-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateScene_Success() {
|
||||
// Arrange
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.description("Updated Description")
|
||||
.order(2)
|
||||
.location("Tavern")
|
||||
.timing("Evening")
|
||||
.atmosphere("Cozy")
|
||||
.playerNarration("Narration")
|
||||
.gmSecretNotes("Secret")
|
||||
.choicesConsequences("Choices")
|
||||
.combatDifficulty("Medium")
|
||||
.enemies("Goblins")
|
||||
.relatedPageIds(List.of("page-1"))
|
||||
.illustrationImageIds(List.of("image-1"))
|
||||
.branches(List.of())
|
||||
.build();
|
||||
when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene));
|
||||
when(sceneRepository.save(any(Scene.class))).thenReturn(testScene);
|
||||
|
||||
// Act
|
||||
Scene result = sceneService.updateScene("scene-1", updatedScene);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(sceneRepository, times(1)).findById("scene-1");
|
||||
verify(sceneRepository, times(1)).save(any(Scene.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateScene_NotFound() {
|
||||
// Arrange
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.build();
|
||||
when(sceneRepository.findById("invalid-id")).thenReturn(Optional.empty());
|
||||
|
||||
// Act & Assert
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> sceneService.updateScene("invalid-id", updatedScene)
|
||||
);
|
||||
assertEquals("Scene non trouvée avec l'ID: invalid-id", exception.getMessage());
|
||||
verify(sceneRepository, times(1)).findById("invalid-id");
|
||||
verify(sceneRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateScene_WithValidBranches() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId("scene-2")
|
||||
.label("Go to scene 2")
|
||||
.build();
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
.chapterId("chapter-1")
|
||||
.build();
|
||||
when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene));
|
||||
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(testScene, scene2, scene3));
|
||||
when(sceneRepository.save(any(Scene.class))).thenReturn(testScene);
|
||||
|
||||
// Act
|
||||
Scene result = sceneService.updateScene("scene-1", updatedScene);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(sceneRepository, times(1)).findById("scene-1");
|
||||
verify(sceneRepository, times(1)).save(any(Scene.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateScene_WithBranchToSelf() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId("scene-1")
|
||||
.label("Self-reference")
|
||||
.build();
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
.chapterId("chapter-1")
|
||||
.build();
|
||||
when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene));
|
||||
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(testScene, scene2));
|
||||
|
||||
// Act & Assert
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> sceneService.updateScene("scene-1", updatedScene)
|
||||
);
|
||||
assertEquals("Une scène ne peut pas se brancher sur elle-même", exception.getMessage());
|
||||
verify(sceneRepository, times(1)).findById("scene-1");
|
||||
verify(sceneRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateScene_WithBranchToDifferentChapter() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId("scene-other-chapter")
|
||||
.label("Go to other chapter")
|
||||
.build();
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
.chapterId("chapter-1")
|
||||
.build();
|
||||
when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene));
|
||||
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(testScene, scene2));
|
||||
|
||||
// Act & Assert
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> sceneService.updateScene("scene-1", updatedScene)
|
||||
);
|
||||
assertTrue(exception.getMessage().contains("n'appartient pas au même chapitre"));
|
||||
verify(sceneRepository, times(1)).findById("scene-1");
|
||||
verify(sceneRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateScene_WithBranchNullTarget() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId(null)
|
||||
.label("Null target")
|
||||
.build();
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
.chapterId("chapter-1")
|
||||
.build();
|
||||
when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene));
|
||||
|
||||
// Act & Assert
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> sceneService.updateScene("scene-1", updatedScene)
|
||||
);
|
||||
assertEquals("Une branche doit avoir une scène de destination", exception.getMessage());
|
||||
verify(sceneRepository, times(1)).findById("scene-1");
|
||||
verify(sceneRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateScene_WithBranchBlankTarget() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId(" ")
|
||||
.label("Blank target")
|
||||
.build();
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
.chapterId("chapter-1")
|
||||
.build();
|
||||
when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene));
|
||||
|
||||
// Act & Assert
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> sceneService.updateScene("scene-1", updatedScene)
|
||||
);
|
||||
assertEquals("Une branche doit avoir une scène de destination", exception.getMessage());
|
||||
verify(sceneRepository, times(1)).findById("scene-1");
|
||||
verify(sceneRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateScene_WithEmptyBranches() {
|
||||
// Arrange
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of())
|
||||
.chapterId("chapter-1")
|
||||
.build();
|
||||
when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene));
|
||||
when(sceneRepository.save(any(Scene.class))).thenReturn(testScene);
|
||||
|
||||
// Act
|
||||
Scene result = sceneService.updateScene("scene-1", updatedScene);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
verify(sceneRepository, times(1)).findById("scene-1");
|
||||
verify(sceneRepository, times(1)).save(any(Scene.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteScene() {
|
||||
// Arrange
|
||||
doNothing().when(sceneRepository).deleteById("scene-1");
|
||||
|
||||
// Act
|
||||
sceneService.deleteScene("scene-1");
|
||||
|
||||
// Assert
|
||||
verify(sceneRepository, times(1)).deleteById("scene-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSceneExists_True() {
|
||||
// Arrange
|
||||
when(sceneRepository.existsById("scene-1")).thenReturn(true);
|
||||
|
||||
// Act
|
||||
boolean result = sceneService.sceneExists("scene-1");
|
||||
|
||||
// Assert
|
||||
assertTrue(result);
|
||||
verify(sceneRepository, times(1)).existsById("scene-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSceneExists_False() {
|
||||
// Arrange
|
||||
when(sceneRepository.existsById("invalid-id")).thenReturn(false);
|
||||
|
||||
// Act
|
||||
boolean result = sceneService.sceneExists("invalid-id");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
verify(sceneRepository, times(1)).existsById("invalid-id");
|
||||
}
|
||||
}
|
||||
64
docs/plan.md
64
docs/plan.md
@@ -513,6 +513,66 @@ L'IA ne reçoit **pas** les binaires — juste un signal de présence (`illustra
|
||||
- URL publique d'une image : `/api/images/{id}/content` (proxy Java — évite d'exposer MinIO directement).
|
||||
- Validation MIME côté `ImageService` : `jpeg/png/webp/gif` uniquement, max 10 Mo.
|
||||
|
||||
## Feature "Branches narratives" ✅ (21 avril 2026, session 7)
|
||||
|
||||
> ✅ **Graphe narratif intra-chapitre livré en 6 étapes.** Une scène peut maintenant pointer vers plusieurs autres scènes du même chapitre selon l'action des joueurs (logique "livre dont vous êtes le héros"). Vue graphique SVG custom (organigramme) accessible depuis `chapter-view`.
|
||||
|
||||
### Cadrage produit
|
||||
- **Scope** : branches intra-chapitre uniquement (cross-chapitre = scène "finale" qui mène au chapitre suivant, logique existante).
|
||||
- **Champ `order` conservé (option A)** : la scène `order=1` devient le point d'entrée du graphe. Possibilité future de migrer vers Option B (`isEntryPoint: boolean`) sans effort majeur.
|
||||
- **Formulaire avant graphe** : édition structurée des branches dans `scene-edit`, visualisation ensuite.
|
||||
|
||||
### Étape 1 — Domain (DDD) ✅
|
||||
- **Nouveau** : `SceneBranch` (Value Object, `@Value @Builder @Jacksonized`) — 3 champs : `label`, `targetSceneId`, `condition`.
|
||||
- **Scene.java** : nouveau champ `List<SceneBranch> branches` + méthodes métier `addBranch()` (garde anti-auto-référence) / `removeBranchTo()`.
|
||||
|
||||
### Étape 2 — Persistance ✅
|
||||
- **Nouveau** : `SceneBranchListJsonConverter` (pattern homogène avec `StringListJsonConverter`, TEXT + JSON via Jackson).
|
||||
- **SceneJpaEntity** : colonne `branches TEXT` avec `@Convert`. Colonne auto-créée par Hibernate `ddl-auto=update`.
|
||||
- **PostgresSceneRepository** : mapping `branches` dans les deux sens avec copies défensives.
|
||||
|
||||
### Étape 3 — API ✅
|
||||
- **Nouveau** : `SceneBranchDTO` (mutable, pour wire Jackson).
|
||||
- **SceneDTO** : champ `branches: List<SceneBranchDTO>`.
|
||||
- **SceneMapper** : helpers `toBranchDTOs()` / `toBranchDomain()` branchés sur `toDTO()` / `toDomain()`.
|
||||
- **Controller inchangé** : `PUT /api/scenes/{id}` accepte déjà les branches via `SceneDTO`.
|
||||
|
||||
### Étape 4 — Service (validation métier) ✅
|
||||
- **SceneService.updateScene()** propage `branches` + appelle `validateBranches()`.
|
||||
- Invariants vérifiés : targetSceneId non vide, pas d'auto-référence, appartenance au même chapitre. Chargement une seule fois des IDs du chapitre (évite N+1).
|
||||
|
||||
### Étape 5 — Frontend (édition) ✅
|
||||
- **campaign.model.ts** : interface `SceneBranch` + champs `branches?` sur `Scene` / `SceneCreate`.
|
||||
- **scene-edit** : nouvelle section expandable "🌿 Branches narratives". Chaque branche = carte avec libellé + `<select>` cibles + condition optionnelle + bouton Retirer. Bouton "+ Ajouter une branche".
|
||||
- Dropdown filtré : la scène courante n'apparaît pas comme cible (impossible de créer une auto-référence depuis l'UI).
|
||||
- Mutation immutable (spread) pour préserver change detection Angular.
|
||||
|
||||
### Étape 6 — Vue graphique (organigramme SVG custom) ✅
|
||||
- **Nouveau composant** `chapter-graph` (route `/campaigns/.../chapters/:id/graph`).
|
||||
- **Layout BFS par niveaux** depuis la scène `order=1` (point d'entrée). Scènes non atteignables regroupées dans un niveau "orphelin" en bas.
|
||||
- **Rendu SVG pur** : pas de dépendance lourde (évite ngx-graph ~200 KB + d3). Nœuds cliquables → ouverture de la scène. Flèches via `marker-end`, labels de branche échelonnés le long de l'arête (fraction `t ∈ [0.25, 0.55]` selon index parmi les sorties du nœud source) pour éviter les chevauchements au milieu.
|
||||
- **Bouton "Carte du chapitre"** dans le header de `chapter-view`.
|
||||
|
||||
### Étape 7 — Visibilité IA des branches ✅ (21 avril 2026, session 7 suite)
|
||||
L'IA voyait les scènes comme une liste plate ; elle peut désormais raisonner sur le graphe narratif.
|
||||
- **Domain Java** : `CampaignStructuralContext.SceneSummary` gagne `branches: List<BranchHint>`. Nouveau VO `BranchHint(label, targetSceneName, condition)` — le nom cible est **résolu côté builder** (l'IA ne voit jamais d'UUID).
|
||||
- **Builder** : `CampaignStructuralContextBuilder.toChapterSummary()` construit une map `id→nom` en une seule passe (évite N+1) puis la passe à `toSceneSummary()` pour la résolution des `targetSceneId`. Fallback `"(scène inconnue)"` si ID orphelin.
|
||||
- **Bridge JSON** : `BrainAiChatClient.sceneSummaryToMap()` sérialise `branches` **uniquement si non vide** (payload léger sur scènes linéaires). `condition` omise si blank.
|
||||
- **Brain Python** : nouveau dataclass `SceneBranchHint` + `SceneSummary.branches` avec `field(default_factory=list)` pour rétrocompat. DTO Pydantic `SceneBranchHintDTO` ajouté ; mapping dans `_to_campaign_context`.
|
||||
- **Prompt (`chat.py`)** : rendu `→ "label" vers TargetSceneName (si : condition)` indenté sous chaque scène. Ligne d'explication ajoutée sous le header campagne pour que l'IA interprète les flèches comme des transitions effectives, pas une décoration.
|
||||
- Validation : `mvn clean compile` OK + `python -m py_compile` OK.
|
||||
|
||||
### Bugfixes post-livraison
|
||||
- **`scene-edit` — dropdown cible vide à l'ouverture** : `<select [value]="branch.targetSceneId">` n'était pas appliqué car les `<option>` en `*ngFor` sont rendues après l'évaluation du binding. Fix : `[selected]` sur chaque option (chaque option contrôle son propre état, indépendant de l'ordre de rendu).
|
||||
- **`expandable-section` — dropdown du `LoreLinkPicker` tronqué** : `overflow: hidden` sur le conteneur clippait les popups en position absolute. Retiré — pas d'animation sur cette section, donc l'overflow était superflu ; les coins arrondis restent intacts (le content n'a pas de background propre).
|
||||
- **`chapter-graph` — labels d'arêtes superposés au milieu** : tous les labels étaient centrés à `(x1+x2)/2`, d'où collision quand plusieurs arêtes traversent la même bande horizontale. Fix : position `t` échelonnée selon l'index parmi les sorties du nœud source.
|
||||
|
||||
### Notes & dette
|
||||
- Layout V1 : lignes droites uniquement (pas de courbes Bézier). Risque de chevauchement si un chapitre a beaucoup de branches croisées — acceptable tant qu'on reste à ≤15 scènes par chapitre. Si besoin, on passera à `ngx-graph` (layout Dagre intégré) ou à un routage d'arêtes style `elkjs`.
|
||||
- Pas encore d'édition des branches directement depuis le graphe (drag des nœuds, ajout visuel d'arête). `scene-edit` reste le point d'entrée d'édition.
|
||||
- `choicesConsequences` (TEXT libre) **conservé** : reste utile pour noter les conséquences narratives non-structurelles qui ne justifient pas une nouvelle scène.
|
||||
- Marquage des scènes orphelines (non atteignables) dans le prompt IA : non fait pour cette itération — à ajouter si les MJ créent fréquemment des îlots narratifs que l'IA devrait signaler.
|
||||
|
||||
## Structure des dossiers
|
||||
|
||||
```
|
||||
@@ -579,6 +639,10 @@ Ces points sont à garder en tête pour de futures refactorisations. Pas bloquan
|
||||
- **Gestion des migrations DB** — actuellement `spring.jpa.hibernate.ddl-auto=update` (auto-alter). Acceptable en dev, **inutilisable en prod** (perte de données possible). À remplacer par Flyway ou Liquibase avant la mise en prod (chaque changement de schéma devra être versionné en fichier SQL).
|
||||
|
||||
## Dernière mise à jour
|
||||
21 avril 2026 (session 7, 3h) — **Branches narratives — étape 7 : visibilité IA + bugfixes** : les branches remontent désormais au system prompt du Brain (domain Java → bridge JSON → Pydantic → `chat.py` avec rendu `→ "label" vers TargetScene`). Résolution `targetSceneId → nom` côté builder pour que l'IA ne voie jamais d'UUID. 3 bugfixes : dropdown cible (`[selected]` sur option), clipping dropdown Lore (`overflow` retiré sur expandable-section), labels graphe échelonnés le long de l'arête.
|
||||
|
||||
21 avril 2026 (session 7) — **Feature "Branches narratives" complète (6 étapes)** : Value Object `SceneBranch` (DDD) + persistance TEXT JSON + validation métier intra-chapitre + formulaire d'édition dans `scene-edit` + vue organigramme SVG custom accessible depuis `chapter-view` (`/graph`). Option A retenue pour l'`order` (point d'entrée = `order=1`). Voir section dédiée "Feature Branches narratives".
|
||||
|
||||
21 avril 2026 (session 6) — **Feature "Illustrations & images" complète (6 étapes)** : MinIO + galeries Arc/Chapter/Scene + refactor `Template.fields` avec types TEXT/IMAGE + champs IMAGE sur Pages + synchro Brain Python (`illustration_count`). Voir section dédiée au-dessus de "Structure des dossiers".
|
||||
|
||||
20 avril 2026 (soir, session 4) — **Split View ↔ Edit : mode consultation livré sur Page / Arc / Chapter / Scene**.
|
||||
|
||||
@@ -19,6 +19,7 @@ export const routes: Routes = [
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<div class="graph-page">
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ chapter?.name || 'Chapitre' }} — Carte</h1>
|
||||
<p class="subtitle">Organigramme des scènes et de leurs branches narratives</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour au chapitre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="graph-empty" *ngIf="scenes.length === 0">
|
||||
<p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p>
|
||||
</div>
|
||||
|
||||
<div class="graph-container" *ngIf="scenes.length > 0">
|
||||
<svg [attr.width]="svgWidth" [attr.height]="svgHeight" class="graph-svg">
|
||||
<defs>
|
||||
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
|
||||
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6b7280" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<g class="edges">
|
||||
<g class="edge" *ngFor="let edge of edges">
|
||||
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
|
||||
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
|
||||
stroke="#6b7280" stroke-width="2"
|
||||
marker-end="url(#arrowhead)" />
|
||||
<text *ngIf="edge.label"
|
||||
[attr.x]="edge.labelX"
|
||||
[attr.y]="edge.labelY"
|
||||
text-anchor="middle"
|
||||
class="edge-label">
|
||||
{{ edge.label }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="nodes">
|
||||
<g class="node" *ngFor="let node of nodes" (click)="openScene(node.id)">
|
||||
<title>{{ node.name }}</title>
|
||||
<rect [attr.x]="node.x" [attr.y]="node.y"
|
||||
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
|
||||
rx="8" ry="8" class="node-box" />
|
||||
<text [attr.x]="node.x + NODE_WIDTH / 2"
|
||||
[attr.y]="node.y + NODE_HEIGHT / 2 + 5"
|
||||
text-anchor="middle"
|
||||
class="node-label">
|
||||
{{ node.displayName }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<small class="graph-hint">
|
||||
💡 Cliquez sur une scène pour l'ouvrir. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
.graph-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #d1d5db;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.graph-svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: pointer;
|
||||
|
||||
.node-box {
|
||||
fill: #ffffff;
|
||||
stroke: #1f2937;
|
||||
stroke-width: 2;
|
||||
transition: fill 0.15s ease, stroke 0.15s ease;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
fill: #1f2937;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover .node-box {
|
||||
fill: #eef2ff;
|
||||
stroke: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
.edge-label {
|
||||
font-size: 0.75rem;
|
||||
fill: #4b5563;
|
||||
font-style: italic;
|
||||
// Halo blanc autour du texte pour garantir la lisibilité même s'il passe
|
||||
// sur une ligne ou un autre élément.
|
||||
paint-order: stroke;
|
||||
stroke: #fafafa;
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.graph-hint {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
204
web/src/app/campaigns/chapter-graph/chapter-graph.component.ts
Normal file
204
web/src/app/campaigns/chapter-graph/chapter-graph.component.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
|
||||
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
|
||||
interface GraphEdge { label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
|
||||
|
||||
/**
|
||||
* Vue graphique d'un chapitre : organigramme des scènes et branches narratives.
|
||||
* Layout custom (BFS par niveaux) en SVG — évite une dépendance lourde type ngx-graph.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-chapter-graph',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, LucideAngularModule],
|
||||
templateUrl: './chapter-graph.component.html',
|
||||
styleUrls: ['./chapter-graph.component.scss']
|
||||
})
|
||||
export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
chapterId = '';
|
||||
chapter: Chapter | null = null;
|
||||
scenes: Scene[] = [];
|
||||
|
||||
nodes: GraphNode[] = [];
|
||||
edges: GraphEdge[] = [];
|
||||
|
||||
readonly NODE_WIDTH = 220;
|
||||
readonly NODE_HEIGHT = 64;
|
||||
readonly H_SPACING = 50;
|
||||
readonly V_SPACING = 90;
|
||||
readonly MAX_LABEL_CHARS = 26;
|
||||
|
||||
svgWidth = 600;
|
||||
svgHeight = 400;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.campaignId = pm.get('campaignId')!;
|
||||
this.arcId = pm.get('arcId')!;
|
||||
this.chapterId = pm.get('chapterId')!;
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
scenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||
this.chapter = chapter;
|
||||
this.scenes = scenes;
|
||||
this.pageTitleService.set(`${chapter.name} — Carte`);
|
||||
this.buildGraph();
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout en niveaux par BFS depuis la scène d'entrée (order le plus bas).
|
||||
* Scènes non atteignables rassemblées dans un niveau "orphelin" tout en bas.
|
||||
*/
|
||||
private buildGraph(): void {
|
||||
if (this.scenes.length === 0) {
|
||||
this.nodes = []; this.edges = [];
|
||||
this.svgWidth = 600; this.svgHeight = 200;
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = [...this.scenes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
const entry = sorted[0];
|
||||
|
||||
const levelOf = new Map<string, number>();
|
||||
levelOf.set(entry.id!, 0);
|
||||
const queue: string[] = [entry.id!];
|
||||
while (queue.length > 0) {
|
||||
const curId = queue.shift()!;
|
||||
const curLevel = levelOf.get(curId)!;
|
||||
const curScene = this.scenes.find(s => s.id === curId);
|
||||
if (!curScene?.branches) continue;
|
||||
for (const b of curScene.branches) {
|
||||
if (!levelOf.has(b.targetSceneId)) {
|
||||
levelOf.set(b.targetSceneId, curLevel + 1);
|
||||
queue.push(b.targetSceneId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reachableMax = levelOf.size > 0 ? Math.max(...Array.from(levelOf.values())) : 0;
|
||||
const orphanLevel = reachableMax + 1;
|
||||
for (const s of this.scenes) {
|
||||
if (!levelOf.has(s.id!)) levelOf.set(s.id!, orphanLevel);
|
||||
}
|
||||
|
||||
const byLevel = new Map<number, Scene[]>();
|
||||
for (const s of this.scenes) {
|
||||
const lvl = levelOf.get(s.id!)!;
|
||||
if (!byLevel.has(lvl)) byLevel.set(lvl, []);
|
||||
byLevel.get(lvl)!.push(s);
|
||||
}
|
||||
|
||||
const maxPerLevel = Math.max(...Array.from(byLevel.values()).map(arr => arr.length));
|
||||
const rowWidth = maxPerLevel * this.NODE_WIDTH + (maxPerLevel - 1) * this.H_SPACING;
|
||||
|
||||
const nodes: GraphNode[] = [];
|
||||
for (const [lvl, arr] of byLevel.entries()) {
|
||||
const count = arr.length;
|
||||
const levelWidth = count * this.NODE_WIDTH + (count - 1) * this.H_SPACING;
|
||||
const startX = (rowWidth - levelWidth) / 2;
|
||||
arr.forEach((s, i) => {
|
||||
nodes.push({
|
||||
id: s.id!,
|
||||
name: s.name,
|
||||
displayName: this.truncate(s.name),
|
||||
x: startX + i * (this.NODE_WIDTH + this.H_SPACING),
|
||||
y: lvl * (this.NODE_HEIGHT + this.V_SPACING)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||
const edges: GraphEdge[] = [];
|
||||
for (const scene of this.scenes) {
|
||||
const from = nodeMap.get(scene.id!);
|
||||
if (!from || !scene.branches) continue;
|
||||
// On positionne chaque label a une fraction t differente de l'arete selon
|
||||
// son index parmi les sorties du meme noeud source. Evite le chevauchement
|
||||
// des labels au milieu quand plusieurs aretes convergent/divergent.
|
||||
const siblings = scene.branches.filter(b => nodeMap.has(b.targetSceneId));
|
||||
const count = siblings.length;
|
||||
siblings.forEach((b, idx) => {
|
||||
const to = nodeMap.get(b.targetSceneId)!;
|
||||
const x1 = from.x + this.NODE_WIDTH / 2;
|
||||
const y1 = from.y + this.NODE_HEIGHT;
|
||||
const x2 = to.x + this.NODE_WIDTH / 2;
|
||||
const y2 = to.y;
|
||||
// t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes.
|
||||
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
|
||||
edges.push({
|
||||
label: b.label,
|
||||
x1, y1, x2, y2,
|
||||
labelX: x1 + (x2 - x1) * t,
|
||||
labelY: y1 + (y2 - y1) * t - 4
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.nodes = nodes;
|
||||
this.edges = edges;
|
||||
this.svgWidth = Math.max(rowWidth + 40, 600);
|
||||
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
|
||||
}
|
||||
|
||||
private truncate(text: string): string {
|
||||
return text.length > this.MAX_LABEL_CHARS
|
||||
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
|
||||
: text;
|
||||
}
|
||||
|
||||
openScene(sceneId: string): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', sceneId]);
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@
|
||||
<p class="view-subtitle">Chapitre</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||
title="Voir l'organigramme des scènes et de leurs branches">
|
||||
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
|
||||
Carte du chapitre
|
||||
</button>
|
||||
<button type="button" class="btn-primary" (click)="editMode()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil } from 'lucide-angular';
|
||||
import { LucideAngularModule, Pencil, Network } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
@@ -26,6 +26,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
||||
})
|
||||
export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Network = Network;
|
||||
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
@@ -105,6 +106,12 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
]);
|
||||
}
|
||||
|
||||
openGraph(): void {
|
||||
this.router.navigate([
|
||||
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'graph'
|
||||
]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
|
||||
@@ -105,6 +105,64 @@
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Branches narratives (graphe intra-chapitre) -->
|
||||
<app-expandable-section title="Branches narratives" icon="🌿">
|
||||
<div class="branches-hint" *ngIf="siblingScenes.length === 0">
|
||||
<small class="field-hint">
|
||||
💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches.
|
||||
Créez d'abord d'autres scènes, puis revenez ici pour les connecter.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="branches-list" *ngIf="siblingScenes.length > 0">
|
||||
<div class="branch-item" *ngFor="let branch of branches; let i = index; trackBy: trackByIndex">
|
||||
<div class="field">
|
||||
<label>Libellé du choix</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="branch.label"
|
||||
(input)="updateBranchLabel(i, $any($event.target).value)"
|
||||
placeholder="Ex: Si les joueurs attaquent le garde" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Scène de destination *</label>
|
||||
<select
|
||||
(change)="updateBranchTarget(i, $any($event.target).value)">
|
||||
<option value="" [selected]="!branch.targetSceneId">— Choisir une scène —</option>
|
||||
<option *ngFor="let s of siblingScenes"
|
||||
[value]="s.id"
|
||||
[selected]="s.id === branch.targetSceneId">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Condition MJ (optionnel)</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="branch.condition || ''"
|
||||
(input)="updateBranchCondition(i, $any($event.target).value)"
|
||||
placeholder="Ex: Jet de Persuasion DD 15 réussi" />
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-remove-branch" (click)="removeBranch(i)"
|
||||
title="Supprimer cette branche">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-add-branch" (click)="addBranch()">
|
||||
+ Ajouter une branche
|
||||
</button>
|
||||
|
||||
<small class="field-hint">
|
||||
Chaque branche représente une "sortie" possible depuis cette scène selon l'action des joueurs.
|
||||
Les cibles sont limitées aux scènes du même chapitre.
|
||||
</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Combat ou rencontre -->
|
||||
<app-expandable-section title="Combat ou rencontre" icon="⚔️">
|
||||
<div class="field">
|
||||
|
||||
@@ -29,3 +29,55 @@
|
||||
gap: 0.4rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// Branches narratives : cartes empilées avec libellé / cible / condition / bouton retirer.
|
||||
.branches-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.branch-item {
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-add-branch {
|
||||
align-self: flex-start;
|
||||
padding: 0.5rem 0.9rem;
|
||||
background: transparent;
|
||||
color: #1f2937;
|
||||
border: 1px dashed #9ca3af;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-remove-branch {
|
||||
align-self: flex-end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: transparent;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CampaignService } from '../../services/campaign.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Scene } from '../../services/campaign.model';
|
||||
import { Campaign, Scene, SceneBranch } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { ExpandableSectionComponent } from '../../shared/expandable-section/expandable-section.component';
|
||||
@@ -54,6 +54,11 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
relatedPageIds: string[] = [];
|
||||
illustrationImageIds: string[] = [];
|
||||
|
||||
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
|
||||
siblingScenes: Scene[] = [];
|
||||
/** Branches narratives (état local mutable, persisté au submit). */
|
||||
branches: SceneBranch[] = [];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
@@ -109,6 +114,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
chapterScenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
@@ -116,13 +122,15 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
|
||||
})
|
||||
).subscribe(({ campaign, allCampaigns, scene, treeData, pages, loreId }) => {
|
||||
).subscribe(({ campaign, allCampaigns, scene, chapterScenes, treeData, pages, loreId }) => {
|
||||
this.scene = scene;
|
||||
this.pageTitleService.set(scene.name);
|
||||
this.loreId = loreId;
|
||||
this.availablePages = pages;
|
||||
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
||||
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
|
||||
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
|
||||
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
|
||||
this.form.patchValue({
|
||||
name: scene.name,
|
||||
description: scene.description ?? '',
|
||||
@@ -170,7 +178,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
combatDifficulty: this.form.value.combatDifficulty,
|
||||
enemies: this.form.value.enemies,
|
||||
relatedPageIds: this.relatedPageIds,
|
||||
illustrationImageIds: this.illustrationImageIds
|
||||
illustrationImageIds: this.illustrationImageIds,
|
||||
branches: this.branches
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde')
|
||||
@@ -189,6 +198,30 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]);
|
||||
}
|
||||
|
||||
// ─────────────── Gestion des branches narratives ───────────────
|
||||
|
||||
trackByIndex = (i: number) => i;
|
||||
|
||||
addBranch(): void {
|
||||
this.branches.push({ label: '', targetSceneId: '', condition: '' });
|
||||
}
|
||||
|
||||
removeBranch(index: number): void {
|
||||
this.branches.splice(index, 1);
|
||||
}
|
||||
|
||||
updateBranchLabel(index: number, value: string): void {
|
||||
this.branches[index].label = value;
|
||||
}
|
||||
|
||||
updateBranchTarget(index: number, value: string): void {
|
||||
this.branches[index].targetSceneId = value;
|
||||
}
|
||||
|
||||
updateBranchCondition(index: number, value: string): void {
|
||||
this.branches[index].condition = value;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
|
||||
@@ -87,6 +87,16 @@ export interface ChapterCreate {
|
||||
illustrationImageIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Branche narrative : sortie possible d'une scène vers une autre du même chapitre.
|
||||
* Pendant TS du Value Object Java SceneBranch.
|
||||
*/
|
||||
export interface SceneBranch {
|
||||
label: string;
|
||||
targetSceneId: string;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -106,6 +116,9 @@ export interface Scene {
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
|
||||
/** Sorties narratives (graphe intra-chapitre). */
|
||||
branches?: SceneBranch[];
|
||||
}
|
||||
|
||||
export interface SceneCreate {
|
||||
@@ -125,4 +138,5 @@ export interface SceneCreate {
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
branches?: SceneBranch[];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
// Pas de overflow: hidden : les dropdowns en position absolute (ex. LoreLinkPicker)
|
||||
// doivent pouvoir deborder du conteneur. Le padding interne evite tout debordement
|
||||
// visuel des coins arrondis en rendu normal.
|
||||
|
||||
&.private {
|
||||
border-color: #7f1d1d;
|
||||
|
||||
Reference in New Issue
Block a user