Refonte du système JDR + système de personnage joueurs / non joueurs :

- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
This commit is contained in:
2026-04-30 10:42:09 +02:00
parent 8e0c1c1017
commit 117334315f
67 changed files with 1610 additions and 255 deletions

View File

@@ -11,6 +11,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@@ -38,7 +39,7 @@ public class NpcServiceTest {
testNpc = Npc.builder()
.id("npc-1")
.name("Borin le forgeron")
.markdownContent("# Borin\nForgeron nain")
.values(new java.util.HashMap<>(Map.of("Notes", "Forgeron nain")))
.campaignId("camp-1")
.order(1)
.build();
@@ -49,7 +50,8 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
Npc result = npcService.createNpc(
new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5));
new NpcService.NpcData("Borin le forgeron", null, null,
Map.of("Notes", "Borin"), null, "camp-1", 5));
assertNotNull(result);
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
@@ -65,7 +67,7 @@ public class NpcServiceTest {
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));
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, "camp-1", null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -77,7 +79,7 @@ public class NpcServiceTest {
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));
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, "camp-1", null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -121,10 +123,11 @@ public class NpcServiceTest {
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));
new NpcService.NpcData("Borin renommé", null, null,
Map.of("Notes", "v2"), null, "camp-1", 7));
assertEquals("Borin renommé", result.getName());
assertEquals("# v2", result.getMarkdownContent());
assertEquals("v2", result.getValues().get("Notes"));
assertEquals(7, result.getOrder());
}
@@ -134,7 +137,8 @@ public class NpcServiceTest {
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));
new NpcService.NpcData("Borin", null, null,
Map.of("Notes", "txt"), null, "camp-1", null));
// testNpc avait order=1 → préservé
assertEquals(1, result.getOrder());
@@ -146,7 +150,7 @@ public class NpcServiceTest {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> npcService.updateNpc("missing",
new NpcService.NpcData("x", null, "camp-1", null)));
new NpcService.NpcData("x", null, null, null, null, "camp-1", null)));
assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any());
}

View File

@@ -153,19 +153,19 @@ public class CampaignStructuralContextBuilderTest {
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.")
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# 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
.values(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.")
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# 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("")
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "")))
.build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
@@ -196,7 +196,7 @@ public class CampaignStructuralContextBuilderTest {
// 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();
.name("Verbeux").values(new java.util.HashMap<>(java.util.Map.of("Histoire", longLine))).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());

View File

@@ -3,12 +3,12 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;

View File

@@ -115,8 +115,13 @@ public class NarrativeEntityContextBuilderTest {
@Test
void testBuild_Character_MarkdownProjected() {
// Refonte 2026-04-30 : les valeurs templates sont projetees individuellement
// dans la map fields (cle = nom du champ template).
Character c = Character.builder()
.id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur")
.id("c-1").name("Aragorn")
.values(new java.util.HashMap<>(java.util.Map.of(
"Histoire", "# Aragorn\nRôdeur",
"Race", "Humain")))
.build();
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
@@ -124,14 +129,17 @@ public class NarrativeEntityContextBuilderTest {
assertEquals("character", ctx.entityType());
assertEquals("Aragorn", ctx.title());
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)"));
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("Histoire"));
assertEquals("Humain", ctx.fields().get("Race"));
}
@Test
void testBuild_Npc_MarkdownProjected() {
Npc n = Npc.builder()
.id("n-1").name("Borin le forgeron")
.markdownContent("# Borin\n**Faction :** Clan Feuillefer")
.values(new java.util.HashMap<>(java.util.Map.of(
"Faction", "Clan Feuillefer",
"Histoire", "# Borin")))
.build();
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
@@ -139,13 +147,14 @@ public class NarrativeEntityContextBuilderTest {
assertEquals("npc", ctx.entityType());
assertEquals("Borin le forgeron", ctx.title());
assertEquals("# Borin\n**Faction :** Clan Feuillefer",
ctx.fields().get("fiche complète (markdown)"));
assertEquals("Clan Feuillefer", ctx.fields().get("Faction"));
assertEquals("# Borin", ctx.fields().get("Histoire"));
}
@Test
void testBuild_Npc_NormalizesCase() {
Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build();
Npc n = Npc.builder().id("n-1").name("Elara")
.values(new java.util.HashMap<>(java.util.Map.of("Notes", "desc"))).build();
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");

View File

@@ -5,10 +5,10 @@ import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

View File

@@ -0,0 +1,139 @@
package com.loremind.domain.gamesystemcontext;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine GameSystem ciblant la gestion des templates PJ/PNJ.
* Le ruleset markdown est testé ailleurs via GameSystemContextSelector.
*/
class GameSystemTest {
// --- addCharacterField --------------------------------------------------
@Test
void addCharacterField_appendsField() {
GameSystem gs = GameSystem.builder().build();
gs.addCharacterField(TemplateField.text("Histoire"));
gs.addCharacterField(TemplateField.image("Portrait", ImageLayout.HERO));
assertEquals(2, gs.getCharacterTemplate().size());
assertEquals("Histoire", gs.getCharacterTemplate().get(0).getName());
assertEquals(FieldType.IMAGE, gs.getCharacterTemplate().get(1).getType());
}
@Test
void addCharacterField_rejectsDuplicateNameCaseInsensitive() {
GameSystem gs = GameSystem.builder().build();
gs.addCharacterField(TemplateField.text("Histoire"));
// Doublon de cle dans Character.values garanti casse-insensible :
// "Histoire" et "histoire" produiraient la meme cle JSON.
assertThrows(IllegalArgumentException.class,
() -> gs.addCharacterField(TemplateField.number("HISTOIRE")));
}
@Test
void addCharacterField_rejectsBlankName() {
GameSystem gs = GameSystem.builder().build();
assertThrows(IllegalArgumentException.class,
() -> gs.addCharacterField(new TemplateField(" ", FieldType.TEXT)));
}
// --- removeCharacterField ----------------------------------------------
@Test
void removeCharacterField_removesByNameCaseInsensitive() {
GameSystem gs = GameSystem.builder()
.characterTemplate(new ArrayList<>(List.of(
TemplateField.text("Histoire"),
TemplateField.text("Notes")
)))
.build();
gs.removeCharacterField("HISTOIRE");
assertEquals(1, gs.getCharacterTemplate().size());
assertEquals("Notes", gs.getCharacterTemplate().get(0).getName());
}
@Test
void removeCharacterField_silentNoOpWhenMissing() {
GameSystem gs = GameSystem.builder()
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Histoire"))))
.build();
gs.removeCharacterField("absent");
assertEquals(1, gs.getCharacterTemplate().size());
}
// --- replaceCharacterTemplate ------------------------------------------
@Test
void replaceCharacterTemplate_overwritesEntireList() {
GameSystem gs = GameSystem.builder()
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Old"))))
.build();
gs.replaceCharacterTemplate(List.of(
TemplateField.text("A"),
TemplateField.number("B")));
assertEquals(2, gs.getCharacterTemplate().size());
assertEquals("A", gs.getCharacterTemplate().get(0).getName());
assertEquals("B", gs.getCharacterTemplate().get(1).getName());
}
@Test
void replaceCharacterTemplate_rejectsDuplicates() {
GameSystem gs = GameSystem.builder().build();
assertThrows(IllegalArgumentException.class,
() -> gs.replaceCharacterTemplate(List.of(
TemplateField.text("a"),
TemplateField.text("A"))));
}
@Test
void replaceCharacterTemplate_nullBecomesEmptyList() {
GameSystem gs = GameSystem.builder().build();
gs.replaceCharacterTemplate(null);
assertTrue(gs.getCharacterTemplate().isEmpty());
}
@Test
void replaceCharacterTemplate_isolatesInternalListFromCallerMutations() {
// Garantie d'encapsulation : muter la liste passee ne doit pas affecter le GameSystem.
List<TemplateField> external = new ArrayList<>(List.of(TemplateField.text("A")));
GameSystem gs = GameSystem.builder().build();
gs.replaceCharacterTemplate(external);
external.add(TemplateField.text("B"));
assertEquals(1, gs.getCharacterTemplate().size());
}
// --- Templates NPC : meme logique, sanity check minimal ----------------
@Test
void npcTemplate_followsSameRulesAsCharacterTemplate() {
GameSystem gs = GameSystem.builder().build();
gs.addNpcField(TemplateField.text("Motivation"));
assertThrows(IllegalArgumentException.class,
() -> gs.addNpcField(TemplateField.text("motivation")));
gs.removeNpcField("Motivation");
assertTrue(gs.getNpcTemplate().isEmpty());
}
}

View File

@@ -1,5 +1,7 @@
package com.loremind.domain.lorecontext;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.junit.jupiter.api.Test;
import java.util.List;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.lorecontext;
package com.loremind.domain.shared.template;
import org.junit.jupiter.api.Test;

View File

@@ -1,8 +1,8 @@
package com.loremind.infrastructure.persistence.converter;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.junit.jupiter.api.Test;
import java.util.List;

View File

@@ -1,10 +1,10 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;

View File

@@ -0,0 +1,108 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration du GameSystemController centres sur la persistance
* des templates PJ/PNJ via l'API REST. Le CRUD de base est suppose stable.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class GameSystemControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Test
void create_persistsCharacterAndNpcTemplates() throws Exception {
GameSystemDTO dto = new GameSystemDTO();
dto.setName("Nimble Test");
dto.setRulesMarkdown("## Combat\n- d20");
dto.setCharacterTemplate(List.of(
new TemplateFieldDTO("Histoire", "TEXT", null),
new TemplateFieldDTO("PV", "NUMBER", null),
new TemplateFieldDTO("Portrait", "IMAGE", "HERO")));
dto.setNpcTemplate(List.of(
new TemplateFieldDTO("Motivation", "TEXT", null)));
mockMvc.perform(post("/api/game-systems")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.characterTemplate.length()").value(3))
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"))
.andExpect(jsonPath("$.characterTemplate[2].layout").value("HERO"))
.andExpect(jsonPath("$.npcTemplate.length()").value(1))
.andExpect(jsonPath("$.npcTemplate[0].name").value("Motivation"));
}
@Test
void update_replacesTemplates() throws Exception {
// Creation initiale avec un seul champ.
GameSystemDTO dto = new GameSystemDTO();
dto.setName("RuleSet");
dto.setCharacterTemplate(List.of(new TemplateFieldDTO("Old", "TEXT", null)));
MvcResult posted = mockMvc.perform(post("/api/game-systems")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andReturn();
GameSystemDTO created = objectMapper.readValue(
posted.getResponse().getContentAsString(), GameSystemDTO.class);
// Replace template integralement.
created.setCharacterTemplate(List.of(
new TemplateFieldDTO("Histoire", "TEXT", null),
new TemplateFieldDTO("Niveau", "NUMBER", null)));
mockMvc.perform(put("/api/game-systems/{id}", created.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(created)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.characterTemplate.length()").value(2))
.andExpect(jsonPath("$.characterTemplate[0].name").value("Histoire"))
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"));
// Verification que le GET independant retourne bien les nouveaux champs (pas de cache stale).
mockMvc.perform(get("/api/game-systems/{id}", created.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Old')]").doesNotExist())
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Histoire')]").exists());
}
@Test
void create_rejectsDuplicateFieldNames() throws Exception {
GameSystemDTO dto = new GameSystemDTO();
dto.setName("BadRules");
dto.setCharacterTemplate(List.of(
new TemplateFieldDTO("Nom", "TEXT", null),
new TemplateFieldDTO("nom", "NUMBER", null)));
mockMvc.perform(post("/api/game-systems")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().is4xxClientError());
}
}

View File

@@ -6,7 +6,7 @@ import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;