Ajout de la possibilité de faire des stats blocs pour tout ce qui est ennemis / créatures adverses.
All checks were successful
All checks were successful
Dorénavant, l'IA est capable de prendre en compte le format des quêtes, chapitres, Arc.... pour proposer des blocs plus complets. Les ennemis sont également référençables directement dans la campagne. Les références vers les ennemis dans la partie "donjon" est en cours d'ajout
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.12.6-beta</version>
|
||||
<version>0.13.0-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -42,15 +42,17 @@ public class CampaignBriefBuilder {
|
||||
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
|
||||
|
||||
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
|
||||
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
|
||||
.append("un arc LINEAR contient des chapitres en séquence._\n");
|
||||
if (cc.arcs().isEmpty()) {
|
||||
sb.append("_(aucun arc pour le moment)_\n");
|
||||
}
|
||||
for (ArcSummary arc : cc.arcs()) {
|
||||
sb.append("### Arc : ").append(arc.name());
|
||||
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
|
||||
if (notBlank(arc.description())) sb.append(" — ").append(arc.description());
|
||||
sb.append("\n");
|
||||
for (ChapterSummary ch : arc.chapters()) {
|
||||
sb.append("- Chapitre : ").append(ch.name());
|
||||
sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name());
|
||||
if (notBlank(ch.description())) sb.append(" — ").append(ch.description());
|
||||
sb.append("\n");
|
||||
for (SceneSummary sc : ch.scenes()) {
|
||||
|
||||
@@ -78,6 +78,11 @@ public class CharacterService {
|
||||
characterRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public List<Character> searchCharacters(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return characterRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
private int nextOrderFor(String playthroughId) {
|
||||
return characterRepository.findByPlaythroughId(playthroughId).stream()
|
||||
.mapToInt(Character::getOrder)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Enemy;
|
||||
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
|
||||
* Miroir de {@link NpcService} : fiche pilotée par le template ENNEMI du GameSystem.
|
||||
*/
|
||||
@Service
|
||||
public class EnemyService {
|
||||
|
||||
private final EnemyRepository enemyRepository;
|
||||
|
||||
public EnemyService(EnemyRepository enemyRepository) {
|
||||
this.enemyRepository = enemyRepository;
|
||||
}
|
||||
|
||||
public record EnemyData(
|
||||
String name,
|
||||
String level,
|
||||
String folder,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
String campaignId,
|
||||
Integer order
|
||||
) {}
|
||||
|
||||
public Enemy createEnemy(EnemyData data) {
|
||||
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
|
||||
Enemy enemy = Enemy.builder()
|
||||
.name(data.name())
|
||||
.level(normalize(data.level()))
|
||||
.folder(normalize(data.folder()))
|
||||
.portraitImageId(data.portraitImageId())
|
||||
.headerImageId(data.headerImageId())
|
||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||
.campaignId(data.campaignId())
|
||||
.order(order)
|
||||
.build();
|
||||
return enemyRepository.save(enemy);
|
||||
}
|
||||
|
||||
public Optional<Enemy> getEnemyById(String id) {
|
||||
return enemyRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Enemy> getEnemiesByCampaignId(String campaignId) {
|
||||
return enemyRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public Enemy updateEnemy(String id, EnemyData data) {
|
||||
Enemy existing = enemyRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setLevel(normalize(data.level()));
|
||||
existing.setFolder(normalize(data.folder()));
|
||||
existing.setPortraitImageId(data.portraitImageId());
|
||||
existing.setHeaderImageId(data.headerImageId());
|
||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
return enemyRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteEnemy(String id) {
|
||||
enemyRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public List<Enemy> searchEnemies(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return enemyRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
/** Trim ; chaîne vide → null (= non renseigné / non classé). */
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return enemyRepository.findByCampaignId(campaignId).stream()
|
||||
.mapToInt(Enemy::getOrder)
|
||||
.max()
|
||||
.orElse(-1) + 1;
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,11 @@ public class ItemCatalogService {
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
public List<ItemCatalog> searchCatalogs(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return repository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
|
||||
public ItemCatalog generateProposal(String campaignId, String description) {
|
||||
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));
|
||||
|
||||
@@ -22,16 +22,19 @@ public class NotebookService {
|
||||
private final NotebookIndexer indexer;
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final CampaignBriefBuilder briefBuilder;
|
||||
private final com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository;
|
||||
|
||||
public NotebookService(
|
||||
NotebookRepository repository,
|
||||
NotebookIndexer indexer,
|
||||
CampaignRepository campaignRepository,
|
||||
CampaignBriefBuilder briefBuilder) {
|
||||
CampaignBriefBuilder briefBuilder,
|
||||
com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository) {
|
||||
this.repository = repository;
|
||||
this.indexer = indexer;
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.briefBuilder = briefBuilder;
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
// --- Notebooks ---
|
||||
@@ -179,6 +182,25 @@ public class NotebookService {
|
||||
if (campaignId == null) return "";
|
||||
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
|
||||
if (campaign == null) return "";
|
||||
return briefBuilder.build(campaign);
|
||||
String brief = briefBuilder.build(campaign);
|
||||
// Champs TEXT de la fiche PNJ du système de jeu : permet à l'IA de remplir
|
||||
// `values` des actions "npc" avec les BONS noms de champs (Histoire,
|
||||
// Apparence…) au lieu de tout entasser dans une description générique.
|
||||
String npcFields = npcSheetFields(campaign.getGameSystemId());
|
||||
return npcFields.isEmpty() ? brief : brief + "\n\n" + npcFields;
|
||||
}
|
||||
|
||||
private String npcSheetFields(String gameSystemId) {
|
||||
if (gameSystemId == null || gameSystemId.isBlank()) return "";
|
||||
var gameSystem = gameSystemRepository.findById(gameSystemId).orElse(null);
|
||||
if (gameSystem == null || gameSystem.getNpcTemplate() == null) return "";
|
||||
var names = gameSystem.getNpcTemplate().stream()
|
||||
.filter(f -> f.getType() == com.loremind.domain.shared.template.FieldType.TEXT)
|
||||
.map(com.loremind.domain.shared.template.TemplateField::getName)
|
||||
.filter(n -> n != null && !n.isBlank())
|
||||
.toList();
|
||||
if (names.isEmpty()) return "";
|
||||
return "FICHE PNJ — champs texte disponibles (clés à utiliser dans `values` "
|
||||
+ "d'une action npc) : " + String.join(", ", names);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,11 @@ public class NpcService {
|
||||
npcRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public List<Npc> searchNpcs(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return npcRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
/** Trim le dossier ; chaîne vide → null (= non classé). */
|
||||
private static String normalizeFolder(String folder) {
|
||||
if (folder == null) return null;
|
||||
|
||||
@@ -85,6 +85,11 @@ public class RandomTableService {
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
public List<RandomTable> searchTables(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return repository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
|
||||
public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
|
||||
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
|
||||
|
||||
@@ -57,6 +57,7 @@ public class GameSystemService {
|
||||
String rulesMarkdown,
|
||||
List<TemplateField> characterTemplate,
|
||||
List<TemplateField> npcTemplate,
|
||||
List<TemplateField> enemyTemplate,
|
||||
String author,
|
||||
boolean isPublic
|
||||
) {}
|
||||
@@ -71,6 +72,7 @@ public class GameSystemService {
|
||||
.build();
|
||||
gameSystem.replaceCharacterTemplate(data.characterTemplate());
|
||||
gameSystem.replaceNpcTemplate(data.npcTemplate());
|
||||
gameSystem.replaceEnemyTemplate(data.enemyTemplate());
|
||||
return gameSystemRepository.save(gameSystem);
|
||||
}
|
||||
|
||||
@@ -90,6 +92,7 @@ public class GameSystemService {
|
||||
existing.setRulesMarkdown(data.rulesMarkdown());
|
||||
existing.replaceCharacterTemplate(data.characterTemplate());
|
||||
existing.replaceNpcTemplate(data.npcTemplate());
|
||||
existing.replaceEnemyTemplate(data.enemyTemplate());
|
||||
existing.setAuthor(normalize(data.author()));
|
||||
existing.setPublic(data.isPublic());
|
||||
return gameSystemRepository.save(existing);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.ArcType;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
@@ -10,6 +11,7 @@ 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.EnemyRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
@@ -48,6 +50,7 @@ public class CampaignStructuralContextBuilder {
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final NpcRepository npcRepository;
|
||||
private final EnemyRepository enemyRepository;
|
||||
|
||||
public CampaignStructuralContextBuilder(
|
||||
CampaignRepository campaignRepository,
|
||||
@@ -55,13 +58,15 @@ public class CampaignStructuralContextBuilder {
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository,
|
||||
NpcRepository npcRepository) {
|
||||
NpcRepository npcRepository,
|
||||
EnemyRepository enemyRepository) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.npcRepository = npcRepository;
|
||||
this.enemyRepository = enemyRepository;
|
||||
}
|
||||
|
||||
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
|
||||
@@ -84,9 +89,17 @@ public class CampaignStructuralContextBuilder {
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Campagne non trouvée avec l'ID: " + campaignId));
|
||||
|
||||
// Libellés du bestiaire (« Nom (niveau) ») chargés UNE fois pour résoudre
|
||||
// les enemyIds des pièces sans N+1 sur le repo.
|
||||
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
|
||||
.collect(Collectors.toMap(
|
||||
com.loremind.domain.campaigncontext.Enemy::getId,
|
||||
CampaignStructuralContextBuilder::enemyLabel,
|
||||
(a, b) -> a));
|
||||
|
||||
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
|
||||
.sorted(Comparator.comparingInt(Arc::getOrder))
|
||||
.map(this::toArcSummary)
|
||||
.map(arc -> toArcSummary(arc, enemyLabelById))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
|
||||
@@ -143,19 +156,20 @@ public class CampaignStructuralContextBuilder {
|
||||
return "";
|
||||
}
|
||||
|
||||
private ArcSummary toArcSummary(Arc arc) {
|
||||
private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
|
||||
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||
.map(this::toChapterSummary)
|
||||
.map(chapter -> toChapterSummary(chapter, enemyLabelById))
|
||||
.collect(Collectors.toList());
|
||||
return new ArcSummary(
|
||||
arc.getName(),
|
||||
arc.getDescription(),
|
||||
arc.getType() == ArcType.HUB,
|
||||
countImages(arc.getIllustrationImageIds()),
|
||||
chapters);
|
||||
}
|
||||
|
||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||
private ChapterSummary toChapterSummary(Chapter chapter, Map<String, String> enemyLabelById) {
|
||||
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
|
||||
.sorted(Comparator.comparingInt(Scene::getOrder))
|
||||
.toList();
|
||||
@@ -166,7 +180,7 @@ public class CampaignStructuralContextBuilder {
|
||||
.collect(Collectors.toMap(Scene::getId, Scene::getName));
|
||||
|
||||
List<SceneSummary> summaries = scenes.stream()
|
||||
.map(s -> toSceneSummary(s, nameById))
|
||||
.map(s -> toSceneSummary(s, nameById, enemyLabelById))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new ChapterSummary(
|
||||
@@ -176,7 +190,8 @@ public class CampaignStructuralContextBuilder {
|
||||
summaries);
|
||||
}
|
||||
|
||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
||||
private SceneSummary toSceneSummary(
|
||||
Scene scene, Map<String, String> nameById, Map<String, String> enemyLabelById) {
|
||||
List<BranchHint> hints = scene.getBranches() == null
|
||||
? List.of()
|
||||
: scene.getBranches().stream()
|
||||
@@ -186,7 +201,7 @@ public class CampaignStructuralContextBuilder {
|
||||
b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<RoomSummary> rooms = toRoomSummaries(scene);
|
||||
List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
|
||||
|
||||
return new SceneSummary(
|
||||
scene.getName(),
|
||||
@@ -202,7 +217,7 @@ public class CampaignStructuralContextBuilder {
|
||||
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
|
||||
* suffisant pour proposer de la narration ou anticiper les choix.
|
||||
*/
|
||||
private List<RoomSummary> toRoomSummaries(Scene scene) {
|
||||
private List<RoomSummary> toRoomSummaries(Scene scene, Map<String, String> enemyLabelById) {
|
||||
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
|
||||
Map<String, String> nameById = scene.getRooms().stream()
|
||||
.collect(Collectors.toMap(
|
||||
@@ -219,11 +234,36 @@ public class CampaignStructuralContextBuilder {
|
||||
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
|
||||
b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints);
|
||||
return new RoomSummary(
|
||||
r.getName(), r.getFloor(), r.getDescription(),
|
||||
roomEnemiesText(r, enemyLabelById), hints);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte « ennemis » d'une pièce pour le prompt : fiches du bestiaire
|
||||
* référencées (libellés résolus, IDs orphelins ignorés) suivies du texte
|
||||
* libre. L'un ou l'autre peut être vide.
|
||||
*/
|
||||
private static String roomEnemiesText(
|
||||
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) {
|
||||
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
|
||||
.map(enemyLabelById::get)
|
||||
.filter(l -> l != null && !l.isBlank())
|
||||
.collect(Collectors.joining(", "));
|
||||
String freeText = room.getEnemies() == null ? "" : room.getEnemies().strip();
|
||||
if (linked.isEmpty()) return freeText;
|
||||
if (freeText.isEmpty()) return linked;
|
||||
return linked + " — " + freeText;
|
||||
}
|
||||
|
||||
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
|
||||
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) {
|
||||
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
|
||||
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
|
||||
}
|
||||
|
||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||
private static int countImages(List<String> ids) {
|
||||
return ids == null ? 0 : ids.size();
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.EnemyRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
@@ -32,18 +33,21 @@ public class NarrativeEntityContextBuilder {
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final NpcRepository npcRepository;
|
||||
private final EnemyRepository enemyRepository;
|
||||
|
||||
public NarrativeEntityContextBuilder(
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository,
|
||||
NpcRepository npcRepository) {
|
||||
NpcRepository npcRepository,
|
||||
EnemyRepository enemyRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.npcRepository = npcRepository;
|
||||
this.enemyRepository = enemyRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,10 +128,41 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "choicesConsequences", s.getChoicesConsequences());
|
||||
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||
putField(fields, "enemies", s.getEnemies());
|
||||
putField(fields, "linkedEnemies", resolveLinkedEnemies(s));
|
||||
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||
return new NarrativeEntityContext("scene", s.getName(), fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les fiches du bestiaire référencées par la scène en une ligne par
|
||||
* ennemi : « Nom (niveau) — champ: valeur ; … ». Valeurs tronquées : le
|
||||
* contexte focus doit camper la rencontre, pas embarquer la fiche complète.
|
||||
* Les IDs orphelins (fiche supprimée) sont ignorés silencieusement.
|
||||
*/
|
||||
private String resolveLinkedEnemies(Scene s) {
|
||||
if (s.getEnemyIds() == null || s.getEnemyIds().isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String enemyId : s.getEnemyIds()) {
|
||||
enemyRepository.findById(enemyId).ifPresent(e -> {
|
||||
if (sb.length() > 0) sb.append("\n");
|
||||
sb.append("- ").append(e.getName());
|
||||
if (e.getLevel() != null && !e.getLevel().isBlank()) {
|
||||
sb.append(" (").append(e.getLevel().trim()).append(")");
|
||||
}
|
||||
String stats = e.getValues().entrySet().stream()
|
||||
.filter(en -> en.getValue() != null && !en.getValue().isBlank())
|
||||
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100))
|
||||
.collect(java.util.stream.Collectors.joining(" ; "));
|
||||
if (!stats.isEmpty()) sb.append(" — ").append(stats);
|
||||
});
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String truncate(String value, int maxLen) {
|
||||
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "…";
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromCharacter(Character c) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
if (c.getValues() != null) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Fiche d'ennemi (monstre/créature) d'une campagne — le bestiaire du MJ.
|
||||
* <p>
|
||||
* Même principe de templating que {@link Npc} : champs universels hard-codés
|
||||
* (nom, niveau, dossier, portrait, bandeau) + champs pilotés par le template
|
||||
* ENNEMI du GameSystem ({@code GameSystem.enemyTemplate} : CA, PV, attaques…).
|
||||
* Classement libre par dossier (« Démons », « Humanoïdes »…).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Enemy {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** Niveau / FP / dangerosité — texte libre (« 5 », « FP 8 », « Boss »). Nullable. */
|
||||
private String level;
|
||||
|
||||
/** Dossier de classement (texte libre). Null = non classé. */
|
||||
private String folder;
|
||||
|
||||
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
|
||||
private String portraitImageId;
|
||||
|
||||
/** ID de l'image header/bannière (champ universel hard-codé). Nullable. */
|
||||
private String headerImageId;
|
||||
|
||||
/** Valeurs TEXT/NUMBER du template ennemi. Jamais null après construction. */
|
||||
private Map<String, String> values;
|
||||
|
||||
/** Valeurs IMAGE du template ennemi (listes d'IDs ordonnées par champ). Jamais null. */
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
|
||||
private Map<String, Map<String, String>> keyValueValues;
|
||||
|
||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||
private String campaignId;
|
||||
|
||||
/** Ordre d'affichage dans la liste. */
|
||||
private int order;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public Map<String, String> getValues() {
|
||||
if (values == null) values = new HashMap<>();
|
||||
return values;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> getImageValues() {
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
return imageValues;
|
||||
}
|
||||
|
||||
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||
return keyValueValues;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,13 @@ public class Room {
|
||||
/** Énemis, créatures, boss éventuels (markdown libre). */
|
||||
private String enemies;
|
||||
|
||||
/**
|
||||
* IDs des fiches du bestiaire ({@link Enemy}) présentes dans la pièce
|
||||
* (weak refs). Complète le texte libre {@code enemies}, comme sur Scene.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> enemyIds = new ArrayList<>();
|
||||
|
||||
/** Loot / récompenses présentes dans la pièce. */
|
||||
private String loot;
|
||||
|
||||
|
||||
@@ -40,7 +40,15 @@ public class Scene {
|
||||
|
||||
// === Combat ou rencontre ===
|
||||
private String combatDifficulty; // Difficulté estimée
|
||||
private String enemies; // Liste des ennemis et créatures
|
||||
private String enemies; // Liste des ennemis et créatures (texte libre)
|
||||
|
||||
/**
|
||||
* IDs des fiches du bestiaire ({@link Enemy}) engagées dans cette rencontre
|
||||
* (weak cross-aggregate references). Complète le texte libre `enemies` :
|
||||
* l'utilisateur peut référencer ses fiches, ou tout écrire à la main, ou les deux.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> enemyIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* IDs des pages du Lore associées à cette scène (weak cross-context references).
|
||||
|
||||
@@ -19,4 +19,7 @@ public interface CharacterRepository {
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||
List<Character> searchByName(String query);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Enemy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des fiches d'ennemis (bestiaire de campagne).
|
||||
*/
|
||||
public interface EnemyRepository {
|
||||
|
||||
Enemy save(Enemy enemy);
|
||||
|
||||
Optional<Enemy> findById(String id);
|
||||
|
||||
List<Enemy> findByCampaignId(String campaignId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||
List<Enemy> searchByName(String query);
|
||||
}
|
||||
@@ -19,4 +19,7 @@ public interface ItemCatalogRepository {
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||
List<ItemCatalog> searchByName(String query);
|
||||
}
|
||||
|
||||
@@ -19,4 +19,7 @@ public interface NpcRepository {
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||
List<Npc> searchByName(String query);
|
||||
}
|
||||
|
||||
@@ -19,4 +19,7 @@ public interface RandomTableRepository {
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||
List<RandomTable> searchByName(String query);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,14 @@ public class GameSystem {
|
||||
*/
|
||||
private List<TemplateField> npcTemplate;
|
||||
|
||||
/**
|
||||
* Template de fiche ENNEMI (monstres/créatures du bestiaire de campagne).
|
||||
* Mêmes règles que {@link #characterTemplate} — distinct du template PNJ :
|
||||
* un ennemi porte des stats de combat (CA, PV, attaques…), pas une
|
||||
* caractérisation narrative.
|
||||
*/
|
||||
private List<TemplateField> enemyTemplate;
|
||||
|
||||
/** Auteur déclaré — futur marketplace. Nullable. */
|
||||
private String author;
|
||||
|
||||
@@ -98,6 +106,10 @@ public class GameSystem {
|
||||
npcTemplate = validateAndCopy(fields);
|
||||
}
|
||||
|
||||
public void replaceEnemyTemplate(List<TemplateField> fields) {
|
||||
enemyTemplate = validateAndCopy(fields);
|
||||
}
|
||||
|
||||
// --- Helpers privés ----------------------------------------------------
|
||||
|
||||
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
|
||||
|
||||
@@ -52,11 +52,15 @@ public record CampaignStructuralContext(
|
||||
/**
|
||||
* Résumé d'un arc : nom + description courte + ses chapitres.
|
||||
*
|
||||
* @param hub true si l'arc est de type HUB : ses chapitres sont des
|
||||
* « quêtes » parallèles (vocabulaire UI). L'IA doit le savoir
|
||||
* pour parler de quêtes et cibler le bon arc.
|
||||
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
|
||||
*/
|
||||
public record ArcSummary(
|
||||
String name,
|
||||
String description,
|
||||
boolean hub,
|
||||
int illustrationCount,
|
||||
List<ChapterSummary> chapters) {
|
||||
}
|
||||
|
||||
@@ -252,9 +252,15 @@ public class BrainChatPayloadBuilder {
|
||||
ArcSummary::name,
|
||||
ArcSummary::description,
|
||||
ArcSummary::illustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.chapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
(map, arc) -> {
|
||||
// Vocabulaire UI : les chapitres d'un arc HUB sont des « quêtes ».
|
||||
if (arc.hub()) {
|
||||
map.put("arc_type", "HUB");
|
||||
}
|
||||
map.put("chapters", arc.chapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Entité JPA des fiches d'ennemis (bestiaire). Mêmes règles que NpcJpaEntity.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "enemies", indexes = {
|
||||
@Index(name = "idx_enemies_campaign_id", columnList = "campaign_id")
|
||||
})
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class EnemyJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
/** Niveau / FP — texte libre. Nullable. */
|
||||
@Column(name = "level")
|
||||
private String level;
|
||||
|
||||
/** Dossier de classement (« Démons », « Humanoïdes »…). Nullable = non classé. */
|
||||
@Column(name = "folder")
|
||||
private String folder;
|
||||
|
||||
@Column(name = "portrait_image_id")
|
||||
private String portraitImageId;
|
||||
|
||||
@Column(name = "header_image_id")
|
||||
private String headerImageId;
|
||||
|
||||
@Convert(converter = StringMapJsonConverter.class)
|
||||
@Column(name = "field_values", columnDefinition = "TEXT")
|
||||
private Map<String, String> values;
|
||||
|
||||
@Convert(converter = StringListMapJsonConverter.class)
|
||||
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
@Convert(converter = StringMapMapJsonConverter.class)
|
||||
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||
private Map<String, Map<String, String>> keyValueValues;
|
||||
|
||||
@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();
|
||||
if (values == null) values = new HashMap<>();
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,11 @@ public class GameSystemJpaEntity {
|
||||
@Column(name = "npc_template", columnDefinition = "TEXT")
|
||||
private List<TemplateField> npcTemplate;
|
||||
|
||||
/** Template ENNEMI (bestiaire) serialise en JSON. */
|
||||
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||
@Column(name = "enemy_template", columnDefinition = "TEXT")
|
||||
private List<TemplateField> enemyTemplate;
|
||||
|
||||
@Column
|
||||
private String author;
|
||||
|
||||
@@ -64,6 +69,7 @@ public class GameSystemJpaEntity {
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
||||
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
||||
if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
|
||||
@@ -75,6 +75,12 @@ public class SceneJpaEntity {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String enemies;
|
||||
|
||||
/** IDs des fiches du bestiaire liées à la rencontre (JSON, weak refs). */
|
||||
@Column(name = "enemy_ids", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<String> enemyIds = new ArrayList<>();
|
||||
|
||||
@Column(name = "related_page_ids", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
@Builder.Default
|
||||
|
||||
@@ -10,4 +10,7 @@ import java.util.List;
|
||||
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
|
||||
|
||||
List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
|
||||
|
||||
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||
List<CharacterJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface EnemyJpaRepository extends JpaRepository<EnemyJpaEntity, Long> {
|
||||
|
||||
List<EnemyJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||
|
||||
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||
List<EnemyJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||
}
|
||||
@@ -10,4 +10,7 @@ import java.util.List;
|
||||
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
|
||||
|
||||
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||
|
||||
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||
List<ItemCatalogJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,7 @@ import java.util.List;
|
||||
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
|
||||
|
||||
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||
|
||||
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||
List<NpcJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,7 @@ import java.util.List;
|
||||
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
|
||||
|
||||
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||
|
||||
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||
List<RandomTableJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
||||
return jpaRepository.existsById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Character> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Character toDomainEntity(CharacterJpaEntity e) {
|
||||
return Character.builder()
|
||||
.id(e.getId().toString())
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Enemy;
|
||||
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresEnemyRepository implements EnemyRepository {
|
||||
|
||||
private final EnemyJpaRepository jpaRepository;
|
||||
|
||||
public PostgresEnemyRepository(EnemyJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enemy save(Enemy enemy) {
|
||||
return toDomainEntity(jpaRepository.save(toJpaEntity(enemy)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Enemy> findById(String id) {
|
||||
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Enemy> 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 List<Enemy> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Enemy toDomainEntity(EnemyJpaEntity e) {
|
||||
return Enemy.builder()
|
||||
.id(e.getId().toString())
|
||||
.name(e.getName())
|
||||
.level(e.getLevel())
|
||||
.folder(e.getFolder())
|
||||
.portraitImageId(e.getPortraitImageId())
|
||||
.headerImageId(e.getHeaderImageId())
|
||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||
.campaignId(e.getCampaignId().toString())
|
||||
.order(e.getOrder())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private EnemyJpaEntity toJpaEntity(Enemy n) {
|
||||
return EnemyJpaEntity.builder()
|
||||
.id(n.getId() != null ? Long.parseLong(n.getId()) : null)
|
||||
.name(n.getName())
|
||||
.level(n.getLevel())
|
||||
.folder(n.getFolder())
|
||||
.portraitImageId(n.getPortraitImageId())
|
||||
.headerImageId(n.getHeaderImageId())
|
||||
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
||||
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
||||
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||
.order(n.getOrder())
|
||||
.createdAt(n.getCreatedAt())
|
||||
.updatedAt(n.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||
.npcTemplate(e.getNpcTemplate() != null
|
||||
? new java.util.ArrayList<>(e.getNpcTemplate())
|
||||
: new java.util.ArrayList<>())
|
||||
.enemyTemplate(e.getEnemyTemplate() != null
|
||||
? new java.util.ArrayList<>(e.getEnemyTemplate())
|
||||
: new java.util.ArrayList<>())
|
||||
.author(e.getAuthor())
|
||||
.isPublic(e.isPublic())
|
||||
.createdAt(e.getCreatedAt())
|
||||
@@ -87,6 +90,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||
.npcTemplate(g.getNpcTemplate() != null
|
||||
? new java.util.ArrayList<>(g.getNpcTemplate())
|
||||
: new java.util.ArrayList<>())
|
||||
.enemyTemplate(g.getEnemyTemplate() != null
|
||||
? new java.util.ArrayList<>(g.getEnemyTemplate())
|
||||
: new java.util.ArrayList<>())
|
||||
.author(g.getAuthor())
|
||||
.isPublic(g.isPublic())
|
||||
.createdAt(g.getCreatedAt())
|
||||
|
||||
@@ -77,6 +77,14 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
|
||||
return jpaRepository.existsById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ItemCatalog> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
|
||||
List<CatalogItem> items = e.getItems().stream()
|
||||
.map(c -> CatalogItem.builder()
|
||||
|
||||
@@ -50,6 +50,13 @@ public class PostgresNpcRepository implements NpcRepository {
|
||||
return jpaRepository.existsById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Npc> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Npc toDomainEntity(NpcJpaEntity e) {
|
||||
return Npc.builder()
|
||||
.id(e.getId().toString())
|
||||
|
||||
@@ -81,6 +81,14 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
|
||||
return jpaRepository.existsById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<RandomTable> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private RandomTable toDomainEntity(RandomTableJpaEntity e) {
|
||||
List<RandomTableEntry> entries = e.getEntries().stream()
|
||||
.map(c -> RandomTableEntry.builder()
|
||||
|
||||
@@ -80,6 +80,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
||||
.choicesConsequences(jpaEntity.getChoicesConsequences())
|
||||
.combatDifficulty(jpaEntity.getCombatDifficulty())
|
||||
.enemies(jpaEntity.getEnemies())
|
||||
.enemyIds(jpaEntity.getEnemyIds() != null
|
||||
? new ArrayList<>(jpaEntity.getEnemyIds())
|
||||
: new ArrayList<>())
|
||||
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
||||
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
||||
: new ArrayList<>())
|
||||
@@ -117,6 +120,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
||||
.choicesConsequences(scene.getChoicesConsequences())
|
||||
.combatDifficulty(scene.getCombatDifficulty())
|
||||
.enemies(scene.getEnemies())
|
||||
.enemyIds(scene.getEnemyIds() != null
|
||||
? new ArrayList<>(scene.getEnemyIds())
|
||||
: new ArrayList<>())
|
||||
.relatedPageIds(scene.getRelatedPageIds() != null
|
||||
? new ArrayList<>(scene.getRelatedPageIds())
|
||||
: new ArrayList<>())
|
||||
|
||||
@@ -16,10 +16,13 @@ public class CharacterController {
|
||||
|
||||
private final CharacterService characterService;
|
||||
private final CharacterMapper characterMapper;
|
||||
private final com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository;
|
||||
|
||||
public CharacterController(CharacterService characterService, CharacterMapper characterMapper) {
|
||||
public CharacterController(CharacterService characterService, CharacterMapper characterMapper,
|
||||
com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository) {
|
||||
this.characterService = characterService;
|
||||
this.characterMapper = characterMapper;
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -43,6 +46,31 @@ public class CharacterController {
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche par nom — alimente la recherche globale (Ctrl+K). Le résultat est
|
||||
* enrichi du campaignId (résolu via le Playthrough) pour que le front puisse
|
||||
* construire la route /campaigns/{c}/playthroughs/{p}/characters/{id}.
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<CharacterSearchDTO>> search(@RequestParam("q") String query) {
|
||||
List<CharacterSearchDTO> out = characterService.searchCharacters(query).stream()
|
||||
.map(c -> new CharacterSearchDTO(
|
||||
c.getId(),
|
||||
c.getName(),
|
||||
c.getPlaythroughId(),
|
||||
c.getPlaythroughId() != null
|
||||
? playthroughRepository.findById(c.getPlaythroughId())
|
||||
.map(com.loremind.domain.playcontext.Playthrough::getCampaignId)
|
||||
.orElse(null)
|
||||
: null))
|
||||
.filter(r -> r.campaignId() != null) // PJ orphelin (legacy) : non navigable → exclu
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(out);
|
||||
}
|
||||
|
||||
/** Résultat de recherche d'un PJ, enrichi pour la navigation. */
|
||||
public record CharacterSearchDTO(String id, String name, String playthroughId, String campaignId) {}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
||||
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.campaigncontext.EnemyService;
|
||||
import com.loremind.domain.campaigncontext.Enemy;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* REST Controller des fiches d'ennemis (bestiaire de campagne).
|
||||
* Réponses = domaine {@link Enemy} sérialisé tel quel (Lombok @Data) ;
|
||||
* requêtes = record dédié (le domaine n'a pas de constructeur no-args).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/enemies")
|
||||
public class EnemyController {
|
||||
|
||||
private final EnemyService enemyService;
|
||||
|
||||
public EnemyController(EnemyService enemyService) {
|
||||
this.enemyService = enemyService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Enemy> create(@RequestBody EnemyRequest req) {
|
||||
return ResponseEntity.ok(enemyService.createEnemy(toData(req)));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<Enemy> getById(@PathVariable String id) {
|
||||
return enemyService.getEnemyById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/campaign/{campaignId}")
|
||||
public ResponseEntity<List<Enemy>> getByCampaign(@PathVariable String campaignId) {
|
||||
return ResponseEntity.ok(enemyService.getEnemiesByCampaignId(campaignId));
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<Enemy>> search(@RequestParam("q") String query) {
|
||||
return ResponseEntity.ok(enemyService.searchEnemies(query));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Enemy> update(@PathVariable String id, @RequestBody EnemyRequest req) {
|
||||
return ResponseEntity.ok(enemyService.updateEnemy(id, toData(req)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String id) {
|
||||
enemyService.deleteEnemy(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private EnemyService.EnemyData toData(EnemyRequest req) {
|
||||
return new EnemyService.EnemyData(
|
||||
req.name(), req.level(), req.folder(),
|
||||
req.portraitImageId(), req.headerImageId(),
|
||||
req.values(), req.imageValues(), req.keyValueValues(),
|
||||
req.campaignId(), req.order());
|
||||
}
|
||||
|
||||
public record EnemyRequest(
|
||||
String name,
|
||||
String level,
|
||||
String folder,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
String campaignId,
|
||||
Integer order) {}
|
||||
}
|
||||
@@ -263,6 +263,7 @@ public class GameSystemController {
|
||||
dto.getRulesMarkdown(),
|
||||
toDomainFields(dto.getCharacterTemplate()),
|
||||
toDomainFields(dto.getNpcTemplate()),
|
||||
toDomainFields(dto.getEnemyTemplate()),
|
||||
dto.getAuthor(),
|
||||
dto.isPublic()
|
||||
);
|
||||
|
||||
@@ -58,6 +58,14 @@ public class ItemCatalogController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<ItemCatalogDTO>> search(@RequestParam("q") String query) {
|
||||
return ResponseEntity.ok(service.searchCatalogs(query).stream()
|
||||
.map(mapper::toDTO)
|
||||
.collect(java.util.stream.Collectors.toList()));
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */
|
||||
@PostMapping("/generate")
|
||||
public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) {
|
||||
|
||||
@@ -43,6 +43,15 @@ public class NpcController {
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<NpcDTO>> search(@RequestParam("q") String query) {
|
||||
List<NpcDTO> dtos = npcService.searchNpcs(query).stream()
|
||||
.map(npcMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
|
||||
@GetMapping("/lore/{loreId}")
|
||||
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {
|
||||
|
||||
@@ -59,6 +59,14 @@ public class RandomTableController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<RandomTableDTO>> search(@RequestParam("q") String query) {
|
||||
return ResponseEntity.ok(service.searchTables(query).stream()
|
||||
.map(mapper::toDTO)
|
||||
.collect(java.util.stream.Collectors.toList()));
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
|
||||
@PostMapping("/generate")
|
||||
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {
|
||||
|
||||
@@ -16,6 +16,8 @@ public class RoomDTO {
|
||||
private String name;
|
||||
private String description;
|
||||
private String enemies;
|
||||
/** IDs des fiches du bestiaire présentes dans la pièce (weak refs). */
|
||||
private List<String> enemyIds = new ArrayList<>();
|
||||
private String loot;
|
||||
private String traps;
|
||||
private String gmNotes;
|
||||
|
||||
@@ -30,6 +30,9 @@ public class SceneDTO {
|
||||
private String combatDifficulty;
|
||||
private String enemies;
|
||||
|
||||
/** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */
|
||||
private List<String> enemyIds = new ArrayList<>();
|
||||
|
||||
/** IDs des pages du Lore liées (weak cross-context references). */
|
||||
private List<String> relatedPageIds = new ArrayList<>();
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ public class GameSystemDTO {
|
||||
private String rulesMarkdown;
|
||||
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
|
||||
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
|
||||
private List<TemplateFieldDTO> enemyTemplate = new ArrayList<>();
|
||||
private String author;
|
||||
private boolean isPublic;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public class GameSystemMapper {
|
||||
dto.setRulesMarkdown(g.getRulesMarkdown());
|
||||
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
|
||||
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
|
||||
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
|
||||
dto.setAuthor(g.getAuthor());
|
||||
dto.setPublic(g.isPublic());
|
||||
return dto;
|
||||
@@ -41,6 +42,7 @@ public class GameSystemMapper {
|
||||
.rulesMarkdown(dto.getRulesMarkdown())
|
||||
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
|
||||
.npcTemplate(toDomainList(dto.getNpcTemplate()))
|
||||
.enemyTemplate(toDomainList(dto.getEnemyTemplate()))
|
||||
.author(dto.getAuthor())
|
||||
.isPublic(dto.isPublic())
|
||||
.build();
|
||||
|
||||
@@ -40,6 +40,9 @@ public class SceneMapper {
|
||||
dto.setChoicesConsequences(scene.getChoicesConsequences());
|
||||
dto.setCombatDifficulty(scene.getCombatDifficulty());
|
||||
dto.setEnemies(scene.getEnemies());
|
||||
dto.setEnemyIds(scene.getEnemyIds() != null
|
||||
? new ArrayList<>(scene.getEnemyIds())
|
||||
: new ArrayList<>());
|
||||
dto.setRelatedPageIds(scene.getRelatedPageIds() != null
|
||||
? new ArrayList<>(scene.getRelatedPageIds())
|
||||
: new ArrayList<>());
|
||||
@@ -74,6 +77,9 @@ public class SceneMapper {
|
||||
.choicesConsequences(dto.getChoicesConsequences())
|
||||
.combatDifficulty(dto.getCombatDifficulty())
|
||||
.enemies(dto.getEnemies())
|
||||
.enemyIds(dto.getEnemyIds() != null
|
||||
? new ArrayList<>(dto.getEnemyIds())
|
||||
: new ArrayList<>())
|
||||
.relatedPageIds(dto.getRelatedPageIds() != null
|
||||
? new ArrayList<>(dto.getRelatedPageIds())
|
||||
: new ArrayList<>())
|
||||
@@ -117,6 +123,9 @@ public class SceneMapper {
|
||||
dto.setName(r.getName());
|
||||
dto.setDescription(r.getDescription());
|
||||
dto.setEnemies(r.getEnemies());
|
||||
dto.setEnemyIds(r.getEnemyIds() != null
|
||||
? new ArrayList<>(r.getEnemyIds())
|
||||
: new ArrayList<>());
|
||||
dto.setLoot(r.getLoot());
|
||||
dto.setTraps(r.getTraps());
|
||||
dto.setGmNotes(r.getGmNotes());
|
||||
@@ -145,6 +154,9 @@ public class SceneMapper {
|
||||
.name(d.getName())
|
||||
.description(d.getDescription())
|
||||
.enemies(d.getEnemies())
|
||||
.enemyIds(d.getEnemyIds() != null
|
||||
? new ArrayList<>(d.getEnemyIds())
|
||||
: new ArrayList<>())
|
||||
.loot(d.getLoot())
|
||||
.traps(d.getTraps())
|
||||
.gmNotes(d.getGmNotes())
|
||||
|
||||
@@ -37,6 +37,7 @@ class CampaignStructuralContextTest {
|
||||
ArcSummary arc = new ArcSummary(
|
||||
"Acte I",
|
||||
"Mise en place",
|
||||
false,
|
||||
1,
|
||||
List.of(chapter));
|
||||
|
||||
@@ -76,7 +77,7 @@ class CampaignStructuralContextTest {
|
||||
|
||||
@Test
|
||||
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
|
||||
ArcSummary arc = new ArcSummary("X", null, 0, List.of());
|
||||
ArcSummary arc = new ArcSummary("X", null, false, 0, List.of());
|
||||
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
|
||||
SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of());
|
||||
|
||||
@@ -92,6 +93,7 @@ class CampaignStructuralContextTest {
|
||||
ArcSummary arc = new ArcSummary(
|
||||
"Acte I",
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
List.of(
|
||||
new ChapterSummary("Ch1", null, 0, List.of()),
|
||||
|
||||
@@ -165,7 +165,7 @@ class BrainChatPayloadBuilderTest {
|
||||
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
|
||||
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of());
|
||||
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
||||
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
||||
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", false, 1, List.of(chapter));
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
@@ -198,7 +198,7 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
||||
ArcSummary arc = new ArcSummary("A", "", false, 0, List.of());
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(arc), List.of(), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
@@ -215,7 +215,7 @@ class BrainChatPayloadBuilderTest {
|
||||
void build_sceneSummary_omitsBranches_whenEmpty() {
|
||||
SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of());
|
||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||
ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(arc), List.of(), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
@@ -234,7 +234,7 @@ class BrainChatPayloadBuilderTest {
|
||||
BranchHint branch = new BranchHint("X", "Y", " ");
|
||||
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of());
|
||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||
ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(arc), List.of(), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
|
||||
Reference in New Issue
Block a user