Changement sur le Readme

Ajout d'une partie spécifique pour des PNJ dans la partie campagne
This commit is contained in:
2026-04-27 15:48:04 +02:00
parent aaebeaa547
commit 389392fd1d
80 changed files with 1771 additions and 719 deletions

View File

@@ -0,0 +1,71 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour les fiches de PNJ (campagne).
*/
@Service
public class NpcService {
private final NpcRepository npcRepository;
public NpcService(NpcRepository npcRepository) {
this.npcRepository = npcRepository;
}
/**
* Parameter Object pour la création / mise à jour d'un Npc.
* `order` est fourni par le controller ; si absent, le service le calcule.
*/
public record NpcData(String name, String markdownContent, String campaignId, Integer order) {}
public Npc createNpc(NpcData data) {
int order = data.order() != null
? data.order()
: nextOrderFor(data.campaignId());
Npc npc = Npc.builder()
.name(data.name())
.markdownContent(data.markdownContent())
.campaignId(data.campaignId())
.order(order)
.build();
return npcRepository.save(npc);
}
public Optional<Npc> getNpcById(String id) {
return npcRepository.findById(id);
}
public List<Npc> getNpcsByCampaignId(String campaignId) {
return npcRepository.findByCampaignId(campaignId);
}
public Npc updateNpc(String id, NpcData data) {
Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setMarkdownContent(data.markdownContent());
if (data.order() != null) {
existing.setOrder(data.order());
}
return npcRepository.save(existing);
}
public void deleteNpc(String id) {
npcRepository.deleteById(id);
}
/** Renvoie la prochaine position libre — append en fin de liste. */
private int nextOrderFor(String campaignId) {
return npcRepository.findByCampaignId(campaignId).stream()
.mapToInt(Npc::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -4,17 +4,20 @@ import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
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.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component;
@@ -42,21 +45,24 @@ public class CampaignStructuralContextBuilder {
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
private final NpcRepository npcRepository;
public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
CharacterRepository characterRepository,
NpcRepository npcRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
this.npcRepository = npcRepository;
}
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
/**
@@ -79,11 +85,17 @@ public class CampaignStructuralContextBuilder {
.map(this::toCharacterSummary)
.collect(Collectors.toList());
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Npc::getOrder))
.map(this::toNpcSummary)
.collect(Collectors.toList());
return new CampaignStructuralContext(
campaign.getName(),
campaign.getDescription(),
arcs,
characters);
characters,
npcs);
}
/**
@@ -95,6 +107,11 @@ public class CampaignStructuralContextBuilder {
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
}
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
private NpcSummary toNpcSummary(Npc n) {
return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent()));
}
private static String extractSnippet(String markdown) {
if (markdown == null || markdown.isBlank()) return "";
String firstLine = markdown.lines()

View File

@@ -3,10 +3,12 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.springframework.stereotype.Component;
@@ -29,22 +31,25 @@ public class NarrativeEntityContextBuilder {
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
private final NpcRepository npcRepository;
public NarrativeEntityContextBuilder(
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
CharacterRepository characterRepository,
NpcRepository npcRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
this.npcRepository = npcRepository;
}
/**
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
*
* @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse)
* @param entityType "arc", "chapter", "scene", "character" ou "npc" (insensible à la casse)
* @param entityId l'ID de l'entité
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
*/
@@ -55,6 +60,7 @@ public class NarrativeEntityContextBuilder {
case "chapter" -> fromChapter(loadChapter(entityId));
case "scene" -> fromScene(loadScene(entityId));
case "character" -> fromCharacter(loadCharacter(entityId));
case "npc" -> fromNpc(loadNpc(entityId));
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
};
}
@@ -81,6 +87,11 @@ public class NarrativeEntityContextBuilder {
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
}
private Npc loadNpc(String id) {
return npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("PNJ non trouvé: " + id));
}
// --- Mapping entité → VO ------------------------------------------------
private NarrativeEntityContext fromArc(Arc a) {
@@ -123,6 +134,12 @@ public class NarrativeEntityContextBuilder {
return new NarrativeEntityContext("character", c.getName(), fields);
}
private NarrativeEntityContext fromNpc(Npc n) {
Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", n.getMarkdownContent());
return new NarrativeEntityContext("npc", n.getName(), fields);
}
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
private static void putField(Map<String, String> target, String key, String value) {
target.put(key, value == null ? "" : value);

View File

@@ -12,9 +12,10 @@ import java.time.LocalDateTime;
* backstory, équipement). Évolution prévue vers un système templaté par
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
* <p>
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
* Évolution prévue : système de templating partagé PJ/PNJ piloté par
* GameSystem pour adapter les blocs aux différents systèmes de JDR.
*/
@Data
@Builder

View File

@@ -0,0 +1,41 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Fiche de personnage non-joueur (PNJ) d'une campagne.
* <p>
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé —
* un PNJ a vocation à porter à terme des invariants métier propres (faction,
* statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ)
* qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité
* PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern).
* <p>
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé
* PJ/PNJ piloté par GameSystem.
* <p>
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent
* gérés via le système Page/Template du LoreContext.
*/
@Data
@Builder
public class Npc {
private String id;
private String name;
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */
private String markdownContent;
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */
private String campaignId;
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Npc;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des fiches de PNJ (campagne).
*/
public interface NpcRepository {
Npc save(Npc npc);
Optional<Npc> findById(String id);
List<Npc> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -22,12 +22,14 @@ import java.util.List;
* Record Java : pur domaine, aucune dépendance technique.
*
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
* @param npcs Personnages non-joueurs (PNJ) de la campagne. Vide si aucun.
*/
public record CampaignStructuralContext(
String campaignName,
String campaignDescription,
List<ArcSummary> arcs,
List<CharacterSummary> characters) {
List<CharacterSummary> characters,
List<NpcSummary> npcs) {
/**
* Résumé d'un PJ : nom + snippet court du markdown.
@@ -39,6 +41,14 @@ public record CampaignStructuralContext(
public record CharacterSummary(String name, String snippet) {
}
/**
* Résumé d'un PNJ : symétrique à {@link CharacterSummary}.
* Snippet court extrait du markdown — la fiche complète est réservée
* à un usage focus (à venir, entity_type="npc").
*/
public record NpcSummary(String name, String snippet) {
}
/**
* Résumé d'un arc : nom + description courte + ses chapitres.
*

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummar
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
@@ -132,6 +133,12 @@ public class BrainChatPayloadBuilder {
.map(this::characterSummaryToMap)
.collect(Collectors.toList()));
}
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
map.put("npcs", ctx.npcs().stream()
.map(this::npcSummaryToMap)
.collect(Collectors.toList()));
}
return map;
}
@@ -144,6 +151,15 @@ public class BrainChatPayloadBuilder {
return map;
}
private Map<String, Object> npcSummaryToMap(NpcSummary n) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", n.name());
if (n.snippet() != null && !n.snippet().isBlank()) {
map.put("snippet", n.snippet());
}
return map;
}
/**
* Helper générique pour sérialiser les entités structurelles (Arc/Chapter/Scene)
* avec name, description et illustration_count conditionnel.

View File

@@ -0,0 +1,55 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour les fiches de PNJ d'une campagne.
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte).
*/
@Entity
@Table(name = "npcs")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NpcJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT")
private String markdownContent;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -0,0 +1,75 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresNpcRepository implements NpcRepository {
private final NpcJpaRepository jpaRepository;
public PostgresNpcRepository(NpcJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Npc save(Npc npc) {
NpcJpaEntity entity = toJpaEntity(npc);
NpcJpaEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
@Override
public Optional<Npc> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<Npc> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private Npc toDomainEntity(NpcJpaEntity e) {
return Npc.builder()
.id(e.getId().toString())
.name(e.getName())
.markdownContent(e.getMarkdownContent())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private NpcJpaEntity toJpaEntity(Npc n) {
Long id = n.getId() != null ? Long.parseLong(n.getId()) : null;
return NpcJpaEntity.builder()
.id(id)
.name(n.getName())
.markdownContent(n.getMarkdownContent())
.campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder())
.createdAt(n.getCreatedAt())
.updatedAt(n.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,62 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.NpcService;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import com.loremind.infrastructure.web.mapper.NpcMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/npcs")
public class NpcController {
private final NpcService npcService;
private final NpcMapper npcMapper;
public NpcController(NpcService npcService, NpcMapper npcMapper) {
this.npcService = npcService;
this.npcMapper = npcMapper;
}
@PostMapping
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
Npc created = npcService.createNpc(
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
);
return ResponseEntity.ok(npcMapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<NpcDTO> getNpcById(@PathVariable String id) {
return npcService.getNpcById(id)
.map(n -> ResponseEntity.ok(npcMapper.toDTO(n)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<NpcDTO>> getNpcsByCampaign(@PathVariable String campaignId) {
List<NpcDTO> dtos = npcService.getNpcsByCampaignId(campaignId).stream()
.map(npcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc(
id,
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
);
return ResponseEntity.ok(npcMapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteNpc(@PathVariable String id) {
npcService.deleteNpc(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
/**
* DTO pour les fiches de PNJ d'une campagne.
*/
@Data
public class NpcDTO {
private String id;
private String name;
private String markdownContent;
private String campaignId;
private int order;
}

View File

@@ -0,0 +1,31 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import org.springframework.stereotype.Component;
@Component
public class NpcMapper {
public NpcDTO toDTO(Npc n) {
if (n == null) return null;
NpcDTO dto = new NpcDTO();
dto.setId(n.getId());
dto.setName(n.getName());
dto.setMarkdownContent(n.getMarkdownContent());
dto.setCampaignId(n.getCampaignId());
dto.setOrder(n.getOrder());
return dto;
}
public Npc toDomain(NpcDTO dto) {
if (dto == null) return null;
return Npc.builder()
.id(dto.getId())
.name(dto.getName())
.markdownContent(dto.getMarkdownContent())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.build();
}
}

View File

@@ -0,0 +1,159 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
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 NpcService.
* Couvre la création (avec auto-calcul de l'order), la lecture, la mise à jour
* (incl. cas non trouvé), la suppression, et le calcul d'order.
*/
@ExtendWith(MockitoExtension.class)
public class NpcServiceTest {
@Mock
private NpcRepository npcRepository;
@InjectMocks
private NpcService npcService;
private Npc testNpc;
@BeforeEach
void setUp() {
testNpc = Npc.builder()
.id("npc-1")
.name("Borin le forgeron")
.markdownContent("# Borin\nForgeron nain")
.campaignId("camp-1")
.order(1)
.build();
}
@Test
void testCreateNpc_WithExplicitOrder() {
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
Npc result = npcService.createNpc(
new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5));
assertNotNull(result);
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
assertEquals(5, captor.getValue().getOrder());
}
@Test
void testCreateNpc_AutoComputesNextOrder_WhenNullProvided() {
// Existant : 2 PNJ avec orders 0 et 3 → next = 4
Npc a = Npc.builder().id("a").campaignId("camp-1").order(0).build();
Npc b = Npc.builder().id("b").campaignId("camp-1").order(3).build();
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Nouveau", null, "camp-1", null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
assertEquals(4, captor.getValue().getOrder());
}
@Test
void testCreateNpc_FirstNpcGetsOrderZero() {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Premier", null, "camp-1", null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
assertEquals(0, captor.getValue().getOrder());
}
@Test
void testGetNpcById_Found() {
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
Optional<Npc> result = npcService.getNpcById("npc-1");
assertTrue(result.isPresent());
assertEquals("Borin le forgeron", result.get().getName());
}
@Test
void testGetNpcById_NotFound() {
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
Optional<Npc> result = npcService.getNpcById("missing");
assertFalse(result.isPresent());
}
@Test
void testGetNpcsByCampaignId_DelegatesToRepository() {
Npc a = Npc.builder().id("a").campaignId("camp-1").order(1).build();
Npc b = Npc.builder().id("b").campaignId("camp-1").order(2).build();
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
List<Npc> result = npcService.getNpcsByCampaignId("camp-1");
assertEquals(2, result.size());
verify(npcRepository).findByCampaignId("camp-1");
}
@Test
void testUpdateNpc_Success() {
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin renommé", "# v2", "camp-1", 7));
assertEquals("Borin renommé", result.getName());
assertEquals("# v2", result.getMarkdownContent());
assertEquals(7, result.getOrder());
}
@Test
void testUpdateNpc_OrderNullPreservesExistingOrder() {
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin", "# txt", "camp-1", null));
// testNpc avait order=1 → préservé
assertEquals(1, result.getOrder());
}
@Test
void testUpdateNpc_NotFoundThrows() {
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> npcService.updateNpc("missing",
new NpcService.NpcData("x", null, "camp-1", null)));
assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any());
}
@Test
void testDeleteNpc_DelegatesToRepository() {
npcService.deleteNpc("npc-1");
verify(npcRepository).deleteById("npc-1");
}
}

View File

@@ -3,12 +3,15 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import org.junit.jupiter.api.BeforeEach;
@@ -43,6 +46,8 @@ public class CampaignStructuralContextBuilderTest {
private SceneRepository sceneRepository;
@Mock
private CharacterRepository characterRepository;
@Mock
private NpcRepository npcRepository;
@InjectMocks
private CampaignStructuralContextBuilder builder;
@@ -144,6 +149,66 @@ public class CampaignStructuralContextBuilderTest {
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
}
@Test
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
.name("Aragorn")
.markdownContent("# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")
.build();
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
.name("Legolas")
.markdownContent(null) // pas de snippet → string vide
.build();
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
.name("Borin le forgeron")
.markdownContent("# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")
.build();
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
.name("Dame Elara")
.markdownContent("")
.build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(characterRepository.findByCampaignId("camp-1")).thenReturn(List.of(pj2, pj1));
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(npc1, npc2));
CampaignStructuralContext ctx = builder.build("camp-1");
// PJ triés par order croissant
assertEquals(2, ctx.characters().size());
assertEquals("Aragorn", ctx.characters().get(0).name());
assertEquals("Rôdeur du Nord, héritier d'Isildur.", ctx.characters().get(0).snippet());
assertEquals("Legolas", ctx.characters().get(1).name());
assertEquals("", ctx.characters().get(1).snippet());
// PNJ triés par order croissant : Elara (1) avant Borin (2)
assertEquals(2, ctx.npcs().size());
assertEquals("Dame Elara", ctx.npcs().get(0).name());
assertEquals("", ctx.npcs().get(0).snippet());
assertEquals("Borin le forgeron", ctx.npcs().get(1).name());
assertEquals("Nain barbu au regard perçant, ancien clan Feuillefer.",
ctx.npcs().get(1).snippet());
}
@Test
void testBuild_TruncatesLongSnippet() {
// Snippet > 160 chars : doit être tronqué à 159 + "…"
String longLine = "x".repeat(200);
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
.name("Verbeux").markdownContent(longLine).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(longNpc));
CampaignStructuralContext ctx = builder.build("camp-1");
String snippet = ctx.npcs().get(0).snippet();
assertEquals(160, snippet.length());
assertTrue(snippet.endsWith(""));
}
@Test
void testBuild_CountsIllustrationsNullSafe() {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1)

View File

@@ -2,9 +2,13 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.junit.jupiter.api.Test;
@@ -30,6 +34,8 @@ public class NarrativeEntityContextBuilderTest {
@Mock private ArcRepository arcRepository;
@Mock private ChapterRepository chapterRepository;
@Mock private SceneRepository sceneRepository;
@Mock private CharacterRepository characterRepository;
@Mock private NpcRepository npcRepository;
@InjectMocks private NarrativeEntityContextBuilder builder;
@@ -107,11 +113,59 @@ public class NarrativeEntityContextBuilderTest {
assertEquals("arc", ctx.entityType());
}
@Test
void testBuild_Character_MarkdownProjected() {
Character c = Character.builder()
.id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur")
.build();
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
NarrativeEntityContext ctx = builder.build("character", "c-1");
assertEquals("character", ctx.entityType());
assertEquals("Aragorn", ctx.title());
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)"));
}
@Test
void testBuild_Npc_MarkdownProjected() {
Npc n = Npc.builder()
.id("n-1").name("Borin le forgeron")
.markdownContent("# Borin\n**Faction :** Clan Feuillefer")
.build();
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
NarrativeEntityContext ctx = builder.build("npc", "n-1");
assertEquals("npc", ctx.entityType());
assertEquals("Borin le forgeron", ctx.title());
assertEquals("# Borin\n**Faction :** Clan Feuillefer",
ctx.fields().get("fiche complète (markdown)"));
}
@Test
void testBuild_Npc_NormalizesCase() {
Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build();
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");
assertEquals("npc", ctx.entityType());
}
@Test
void testBuild_NpcNotFoundThrows() {
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("npc", "missing"));
assertTrue(ex.getMessage().contains("missing"));
}
@Test
void testBuild_UnknownTypeThrows() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("npc", "id"));
assertTrue(ex.getMessage().contains("npc"));
() -> builder.build("alien", "id"));
assertTrue(ex.getMessage().contains("alien"));
}
@Test

View File

@@ -55,7 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of());
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of(), List.of());
messages = List.of();
onUsage = mock(Consumer.class);
onToken = mock(Consumer.class);

View File

@@ -43,6 +43,7 @@ class CampaignStructuralContextTest {
"Les Ombres",
"Une campagne dark fantasy",
List.of(arc),
List.of(),
List.of());
assertEquals("Les Ombres", ctx.campaignName());

View File

@@ -56,7 +56,7 @@ class ChatRequestTest {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.campaignContext(new CampaignStructuralContext(
"Les Ombres", "...", List.of(), List.of()))
"Les Ombres", "...", List.of(), List.of(), List.of()))
.narrativeEntity(new NarrativeEntityContext(
"scene", "L'auberge", Map.of("location", "Taverne")))
.build();

View File

@@ -167,7 +167,7 @@ class BrainChatPayloadBuilderTest {
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"Les Ombres", "dark fantasy", List.of(arc), List.of());
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -200,7 +200,7 @@ class BrainChatPayloadBuilderTest {
void build_arcSummary_omitsIllustrationCount_whenZero() {
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of());
"X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -217,7 +217,7 @@ class BrainChatPayloadBuilderTest {
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of());
"X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -236,7 +236,7 @@ class BrainChatPayloadBuilderTest {
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of());
"X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -269,7 +269,7 @@ class BrainChatPayloadBuilderTest {
@Test
void build_campaignScenario_includesBothContextsAndEntity() {
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(), List.of());
"X", "", List.of(), List.of(), List.of());
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
ChatRequest req = ChatRequest.builder()
.messages(sampleMessages)