Compare commits

...

5 Commits

Author SHA1 Message Date
c77c0bc994 référencement des ennemis dans les lieux d'une quête ou d'un chapitre
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m33s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m53s
2026-06-13 11:08:16 +02:00
6035df262d Ajout de la possibilité de faire des stats blocs pour tout ce qui est ennemis / créatures adverses.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m32s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m48s
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
2026-06-12 23:38:43 +02:00
809e00ce49 Ajout de la possibilité d'archiver le chat dans l'atelier PDF + IA, ainsi que de référencer l'archive dans la conversation actuelle.
All checks were successful
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (brain) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 27s
Build & Push Images / build (web) (push) Successful in 1m57s
Le chat est limité à 16 000 caractères pour l'archive et le début est tronqué pour laisser plutôt la conclusion en visibilité.
Passage bêta 0.12.6
2026-06-12 16:57:57 +02:00
bc0cbb0f7b Correction sur le NotebookController....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m34s
Build & Push Images / build (core) (push) Successful in 2m1s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m50s
2026-06-12 16:05:34 +02:00
6740ed2177 Mise en place de la sélection des source que l'on souhaite que ce soit la partie RAG ou la partie analyse approfondie : on est plus obligé d'envoyer tous les PDFs qu'on a dans la partie atelier PDF + IA.
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m41s
Build & Push Images / build (core) (push) Failing after 1m46s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m54s
Les réponses ne ce baseront que sur les sources que l'on aura cocher au préalable
2026-06-12 15:58:45 +02:00
115 changed files with 3342 additions and 170 deletions

View File

@@ -34,23 +34,59 @@ Règles :
PROPOSITIONS D'INTÉGRATION (IMPORTANT) : PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ, Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
une scène, un chapitre, un arc, une table aléatoire), termine ta réponse par un ou une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par
plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture ```loremind-action. un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture
L'interface les transformera en boutons « Créer dans la campagne ». N'en mets que si ```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ».
c'est pertinent et explicitement souhaité. Formats acceptés : Si l'utilisateur demande PLUSIEURS éléments (« propose-moi 3 quêtes »), produis UN bloc
par élément. N'en mets pas si l'utilisateur pose une simple question.
VOCABULAIRE DE LA CAMPAGNE : une « quête » n'est PAS un type à part — c'est un CHAPITRE
rangé dans un arc de type HUB (quêtes parallèles, sans ordre imposé), tandis qu'un arc
LINEAR contient des chapitres joués en séquence. Donc :
- demande de QUÊTE → action "chapter" (l'utilisateur la placera dans son arc HUB) ;
s'il n'a aucun arc HUB dans sa campagne, propose AUSSI une action "arc" avec
"arcType": "HUB" pour les accueillir.
- demande de CHAPITRE → action "chapter" (destinée plutôt à un arc LINEAR).
RÈGLE CLÉ : remplis TOUS les champs pour lesquels tu as de la matière — pas seulement
le résumé ou les notes MJ. Chaque champ rempli atterrit au bon endroit de la fiche ;
un champ laissé vide est une fiche que l'utilisateur devra compléter à la main. Vise
2 à 5 phrases concrètes par champ narratif, tirées de la source et de la campagne.
Omets simplement un champ si tu n'as rien de précis à y mettre. Formats acceptés :
```loremind-action ```loremind-action
{{"type": "npc", "name": "Nom", "description": "Fiche en quelques phrases."}} {{"type": "npc", "name": "Nom",
"description": "Résumé du PNJ (rôle, apparence, motivation).",
"values": {{"<champ de la fiche PNJ>": "contenu", "<autre champ>": "contenu"}}}}
```
(`values` : utilise comme clés les CHAMPS DE LA FICHE PNJ listés dans le contexte
campagne s'ils y figurent — ex. "Histoire", "Apparence" — sinon omets `values`.)
```loremind-action
{{"type": "scene", "name": "Nom",
"description": "Résumé court de la scène.",
"location": "Lieu précis", "timing": "Quand elle survient",
"atmosphere": "Ambiance sensorielle (sons, odeurs, lumière…)",
"playerNarration": "Texte d'ambiance À LIRE AUX JOUEURS, immersif, à la 2e personne.",
"gmSecretNotes": "Secrets, vérités cachées, notes pour le MJ uniquement.",
"choicesConsequences": "Choix offerts aux joueurs et leurs conséquences.",
"combatDifficulty": "Difficulté du combat éventuel", "enemies": "Ennemis présents (effectifs, tactiques)"}}
``` ```
```loremind-action ```loremind-action
{{"type": "scene", "name": "Nom", "description": "Résumé", "content": "Déroulé détaillé."}} {{"type": "chapter", "name": "Nom",
"description": "Résumé du chapitre (ou de la quête).",
"playerObjectives": "Objectifs tels que les joueurs les perçoivent.",
"narrativeStakes": "Enjeux narratifs (ce qui se joue vraiment).",
"gmNotes": "Notes MJ : fils à tirer, points d'attention."}}
``` ```
```loremind-action ```loremind-action
{{"type": "chapter", "name": "Nom", "description": "Résumé du chapitre."}} {{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR",
``` "themes": "Thèmes de l'arc", "stakes": "Enjeux",
```loremind-action "rewards": "Récompenses attendues", "resolution": "Issues possibles",
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR"}} "gmNotes": "Notes MJ."}}
``` ```
(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de
quêtes parallèles.)
```loremind-action ```loremind-action
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}} {{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
``` ```

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.12.4-beta", version="0.13.0-beta",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.12.4-beta</version> <version>0.13.0-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -42,15 +42,17 @@ public class CampaignBriefBuilder {
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\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()) { if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n"); sb.append("_(aucun arc pour le moment)_\n");
} }
for (ArcSummary arc : cc.arcs()) { 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()); if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n"); sb.append("\n");
for (ChapterSummary ch : arc.chapters()) { 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()); if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n"); sb.append("\n");
for (SceneSummary sc : ch.scenes()) { for (SceneSummary sc : ch.scenes()) {

View File

@@ -78,6 +78,11 @@ public class CharacterService {
characterRepository.deleteById(id); 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) { private int nextOrderFor(String playthroughId) {
return characterRepository.findByPlaythroughId(playthroughId).stream() return characterRepository.findByPlaythroughId(playthroughId).stream()
.mapToInt(Character::getOrder) .mapToInt(Character::getOrder)

View File

@@ -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;
}
}

View File

@@ -82,6 +82,11 @@ public class ItemCatalogService {
repository.deleteById(id); 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. */ /** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
public ItemCatalog generateProposal(String campaignId, String description) { public ItemCatalog generateProposal(String campaignId, String description) {
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId)); ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));

View File

@@ -22,16 +22,19 @@ public class NotebookService {
private final NotebookIndexer indexer; private final NotebookIndexer indexer;
private final CampaignRepository campaignRepository; private final CampaignRepository campaignRepository;
private final CampaignBriefBuilder briefBuilder; private final CampaignBriefBuilder briefBuilder;
private final com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository;
public NotebookService( public NotebookService(
NotebookRepository repository, NotebookRepository repository,
NotebookIndexer indexer, NotebookIndexer indexer,
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
CampaignBriefBuilder briefBuilder) { CampaignBriefBuilder briefBuilder,
com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository) {
this.repository = repository; this.repository = repository;
this.indexer = indexer; this.indexer = indexer;
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.briefBuilder = briefBuilder; this.briefBuilder = briefBuilder;
this.gameSystemRepository = gameSystemRepository;
} }
// --- Notebooks --- // --- Notebooks ---
@@ -119,6 +122,58 @@ public class NotebookService {
.notebookId(notebookId).role(role).content(content).build()); .notebookId(notebookId).role(role).content(content).build());
} }
/** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */
public void clearChat(String notebookId) {
repository.archiveMessagesByNotebookId(notebookId);
}
/** Messages archivés, chronologiques — l'appelant regroupe par {@code archivedAt}. */
public List<NotebookMessage> getArchivedMessages(String notebookId) {
return repository.findArchivedMessagesByNotebookId(notebookId);
}
// Budget total (caractères ≈ tokens/4) des archives injectées en référence :
// borne le prompt même si l'utilisateur coche plusieurs longues conversations.
private static final int ARCHIVE_CONTEXT_MAX_CHARS = 16000;
/**
* Bloc de contexte construit à partir des archives COCHÉES par l'utilisateur
* (clés = {@code archivedAt.toString()}). Injecté dans le prompt du chat pour
* que l'IA puisse s'appuyer sur d'anciennes conversations. Chaîne vide si
* aucune clé valide. Chaque archive est tronquée PAR LE DÉBUT au-delà de son
* budget : la fin d'une conversation (conclusions) est la partie utile.
*/
public String buildArchiveContext(String notebookId, List<String> archivedAtKeys) {
if (archivedAtKeys == null || archivedAtKeys.isEmpty()) return "";
var wanted = new java.util.HashSet<>(archivedAtKeys);
var groups = new java.util.LinkedHashMap<java.time.LocalDateTime, List<NotebookMessage>>();
for (NotebookMessage m : repository.findArchivedMessagesByNotebookId(notebookId)) {
if (m.getArchivedAt() != null && wanted.contains(m.getArchivedAt().toString())) {
groups.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>()).add(m);
}
}
if (groups.isEmpty()) return "";
int budgetPerArchive = Math.max(2000, ARCHIVE_CONTEXT_MAX_CHARS / groups.size());
StringBuilder out = new StringBuilder(
"--- ANCIENNES CONVERSATIONS DE CET ATELIER (références choisies par le MJ : "
+ "tu peux t'appuyer sur leurs conclusions) ---\n");
groups.forEach((archivedAt, messages) -> {
StringBuilder convo = new StringBuilder();
for (NotebookMessage m : messages) {
convo.append("user".equals(m.getRole()) ? "MJ : " : "IA : ")
.append(m.getContent()).append('\n');
}
String text = convo.toString();
if (text.length() > budgetPerArchive) {
text = "[…début tronqué…]\n" + text.substring(text.length() - budgetPerArchive);
}
out.append("[Archive du ").append(archivedAt).append("]\n").append(text).append('\n');
});
out.append("--- FIN DES ANCIENNES CONVERSATIONS ---");
return out.toString();
}
// --- Contexte campagne (oriente l'IA) --- // --- Contexte campagne (oriente l'IA) ---
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) : /** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :
@@ -127,6 +182,25 @@ public class NotebookService {
if (campaignId == null) return ""; if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null); Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return ""; 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);
} }
} }

View File

@@ -102,6 +102,11 @@ public class NpcService {
npcRepository.deleteById(id); 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é). */ /** Trim le dossier ; chaîne vide → null (= non classé). */
private static String normalizeFolder(String folder) { private static String normalizeFolder(String folder) {
if (folder == null) return null; if (folder == null) return null;

View File

@@ -85,6 +85,11 @@ public class RandomTableService {
repository.deleteById(id); 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. */ /** 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) { public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula; String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;

View File

@@ -57,6 +57,7 @@ public class GameSystemService {
String rulesMarkdown, String rulesMarkdown,
List<TemplateField> characterTemplate, List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate, List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String author, String author,
boolean isPublic boolean isPublic
) {} ) {}
@@ -71,6 +72,7 @@ public class GameSystemService {
.build(); .build();
gameSystem.replaceCharacterTemplate(data.characterTemplate()); gameSystem.replaceCharacterTemplate(data.characterTemplate());
gameSystem.replaceNpcTemplate(data.npcTemplate()); gameSystem.replaceNpcTemplate(data.npcTemplate());
gameSystem.replaceEnemyTemplate(data.enemyTemplate());
return gameSystemRepository.save(gameSystem); return gameSystemRepository.save(gameSystem);
} }
@@ -90,6 +92,7 @@ public class GameSystemService {
existing.setRulesMarkdown(data.rulesMarkdown()); existing.setRulesMarkdown(data.rulesMarkdown());
existing.replaceCharacterTemplate(data.characterTemplate()); existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate()); existing.replaceNpcTemplate(data.npcTemplate());
existing.replaceEnemyTemplate(data.enemyTemplate());
existing.setAuthor(normalize(data.author())); existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic()); existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing); return gameSystemRepository.save(existing);

View File

@@ -1,6 +1,7 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character; 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.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; 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.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
@@ -48,6 +50,7 @@ public class CampaignStructuralContextBuilder {
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final EnemyRepository enemyRepository;
public CampaignStructuralContextBuilder( public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
@@ -55,13 +58,15 @@ public class CampaignStructuralContextBuilder {
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository, CharacterRepository characterRepository,
NpcRepository npcRepository) { NpcRepository npcRepository,
EnemyRepository enemyRepository) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.enemyRepository = enemyRepository;
} }
/** Longueur max du snippet de PJ/PNJ 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é). */
@@ -84,9 +89,17 @@ public class CampaignStructuralContextBuilder {
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId)); "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() List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Arc::getOrder)) .sorted(Comparator.comparingInt(Arc::getOrder))
.map(this::toArcSummary) .map(arc -> toArcSummary(arc, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank()) List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
@@ -143,19 +156,20 @@ public class CampaignStructuralContextBuilder {
return ""; return "";
} }
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream() List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary) .map(chapter -> toChapterSummary(chapter, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new ArcSummary( return new ArcSummary(
arc.getName(), arc.getName(),
arc.getDescription(), arc.getDescription(),
arc.getType() == ArcType.HUB,
countImages(arc.getIllustrationImageIds()), countImages(arc.getIllustrationImageIds()),
chapters); chapters);
} }
private ChapterSummary toChapterSummary(Chapter chapter) { private ChapterSummary toChapterSummary(Chapter chapter, Map<String, String> enemyLabelById) {
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream() List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
.sorted(Comparator.comparingInt(Scene::getOrder)) .sorted(Comparator.comparingInt(Scene::getOrder))
.toList(); .toList();
@@ -166,7 +180,7 @@ public class CampaignStructuralContextBuilder {
.collect(Collectors.toMap(Scene::getId, Scene::getName)); .collect(Collectors.toMap(Scene::getId, Scene::getName));
List<SceneSummary> summaries = scenes.stream() List<SceneSummary> summaries = scenes.stream()
.map(s -> toSceneSummary(s, nameById)) .map(s -> toSceneSummary(s, nameById, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new ChapterSummary( return new ChapterSummary(
@@ -176,7 +190,8 @@ public class CampaignStructuralContextBuilder {
summaries); 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<BranchHint> hints = scene.getBranches() == null
? List.of() ? List.of()
: scene.getBranches().stream() : scene.getBranches().stream()
@@ -186,7 +201,7 @@ public class CampaignStructuralContextBuilder {
b.condition())) b.condition()))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<RoomSummary> rooms = toRoomSummaries(scene); List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
return new SceneSummary( return new SceneSummary(
scene.getName(), scene.getName(),
@@ -202,7 +217,7 @@ public class CampaignStructuralContextBuilder {
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est * connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
* suffisant pour proposer de la narration ou anticiper les choix. * 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(); if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream() Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
@@ -219,11 +234,36 @@ public class CampaignStructuralContextBuilder {
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"), nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition())) b.condition()))
.collect(Collectors.toList()); .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()); .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). */ /** Helper defensif : compte les illustrations attachees (null-safe). */
private static int countImages(List<String> ids) { private static int countImages(List<String> ids) {
return ids == null ? 0 : ids.size(); return ids == null ? 0 : ids.size();

View File

@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; 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.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -32,18 +33,21 @@ public class NarrativeEntityContextBuilder {
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final EnemyRepository enemyRepository;
public NarrativeEntityContextBuilder( public NarrativeEntityContextBuilder(
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository, CharacterRepository characterRepository,
NpcRepository npcRepository) { NpcRepository npcRepository,
EnemyRepository enemyRepository) {
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.enemyRepository = enemyRepository;
} }
/** /**
@@ -124,10 +128,41 @@ public class NarrativeEntityContextBuilder {
putField(fields, "choicesConsequences", s.getChoicesConsequences()); putField(fields, "choicesConsequences", s.getChoicesConsequences());
putField(fields, "combatDifficulty", s.getCombatDifficulty()); putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies()); putField(fields, "enemies", s.getEnemies());
putField(fields, "linkedEnemies", resolveLinkedEnemies(s));
putField(fields, "gmSecretNotes", s.getGmSecretNotes()); putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return new NarrativeEntityContext("scene", s.getName(), fields); 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) { private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
if (c.getValues() != null) { if (c.getValues() != null) {

View File

@@ -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;
}
}

View File

@@ -17,4 +17,6 @@ public class NotebookMessage {
private String role; private String role;
private String content; private String content;
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** Null = conversation active ; sinon horodatage du « vider » (lot d'archive). */
private LocalDateTime archivedAt;
} }

View File

@@ -38,6 +38,13 @@ public class Room {
/** Énemis, créatures, boss éventuels (markdown libre). */ /** Énemis, créatures, boss éventuels (markdown libre). */
private String enemies; 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. */ /** Loot / récompenses présentes dans la pièce. */
private String loot; private String loot;

View File

@@ -40,7 +40,15 @@ public class Scene {
// === Combat ou rencontre === // === Combat ou rencontre ===
private String combatDifficulty; // Difficulté estimée 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). * IDs des pages du Lore associées à cette scène (weak cross-context references).

View File

@@ -19,4 +19,7 @@ public interface CharacterRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Character> searchByName(String query);
} }

View File

@@ -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);
}

View File

@@ -19,4 +19,7 @@ public interface ItemCatalogRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<ItemCatalog> searchByName(String query);
} }

View File

@@ -28,5 +28,10 @@ public interface NotebookRepository {
// --- Messages (conversation) --- // --- Messages (conversation) ---
NotebookMessage saveMessage(NotebookMessage message); NotebookMessage saveMessage(NotebookMessage message);
/** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessage> findMessagesByNotebookId(String notebookId); List<NotebookMessage> findMessagesByNotebookId(String notebookId);
/** « Vider » : archive le fil actif en un lot horodaté (rien n'est supprimé). */
void archiveMessagesByNotebookId(String notebookId);
/** Messages archivés, chronologiques (regroupables par {@code archivedAt}). */
List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId);
} }

View File

@@ -19,4 +19,7 @@ public interface NpcRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Npc> searchByName(String query);
} }

View File

@@ -19,4 +19,7 @@ public interface RandomTableRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<RandomTable> searchByName(String query);
} }

View File

@@ -50,6 +50,14 @@ public class GameSystem {
*/ */
private List<TemplateField> npcTemplate; 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. */ /** Auteur déclaré — futur marketplace. Nullable. */
private String author; private String author;
@@ -98,6 +106,10 @@ public class GameSystem {
npcTemplate = validateAndCopy(fields); npcTemplate = validateAndCopy(fields);
} }
public void replaceEnemyTemplate(List<TemplateField> fields) {
enemyTemplate = validateAndCopy(fields);
}
// --- Helpers privés ---------------------------------------------------- // --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) { private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {

View File

@@ -52,11 +52,15 @@ public record CampaignStructuralContext(
/** /**
* Résumé d'un arc : nom + description courte + ses chapitres. * 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). * @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
*/ */
public record ArcSummary( public record ArcSummary(
String name, String name,
String description, String description,
boolean hub,
int illustrationCount, int illustrationCount,
List<ChapterSummary> chapters) { List<ChapterSummary> chapters) {
} }

View File

@@ -252,9 +252,15 @@ public class BrainChatPayloadBuilder {
ArcSummary::name, ArcSummary::name,
ArcSummary::description, ArcSummary::description,
ArcSummary::illustrationCount, ArcSummary::illustrationCount,
(map, arc) -> map.put("chapters", arc.chapters().stream() (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) .map(this::chapterSummaryToMap)
.collect(Collectors.toList()))); .collect(Collectors.toList()));
});
} }
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) { private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {

View File

@@ -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();
}
}

View File

@@ -46,6 +46,11 @@ public class GameSystemJpaEntity {
@Column(name = "npc_template", columnDefinition = "TEXT") @Column(name = "npc_template", columnDefinition = "TEXT")
private List<TemplateField> npcTemplate; 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 @Column
private String author; private String author;
@@ -64,6 +69,7 @@ public class GameSystemJpaEntity {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (characterTemplate == null) characterTemplate = new ArrayList<>(); if (characterTemplate == null) characterTemplate = new ArrayList<>();
if (npcTemplate == null) npcTemplate = new ArrayList<>(); if (npcTemplate == null) npcTemplate = new ArrayList<>();
if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
} }
@PreUpdate @PreUpdate

View File

@@ -34,6 +34,14 @@ public class NotebookMessageJpaEntity {
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/**
* Null = message de la conversation ACTIVE. Non-null = message archivé lors
* d'un « vider la conversation » ; tous les messages d'un même clear portent
* le même horodatage, qui sert d'identifiant de lot d'archive.
*/
@Column(name = "archived_at")
private LocalDateTime archivedAt;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now(); if (createdAt == null) createdAt = LocalDateTime.now();

View File

@@ -75,6 +75,12 @@ public class SceneJpaEntity {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String enemies; 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") @Column(name = "related_page_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class) @Convert(converter = StringListJsonConverter.class)
@Builder.Default @Builder.Default

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> { public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId); List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<CharacterJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -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);
}

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> { public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<ItemCatalogJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -2,12 +2,27 @@ package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity; import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Repository @Repository
public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> { public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> {
List<NotebookMessageJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId); /** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long notebookId);
/** Messages archivés (tous lots confondus, l'appelant regroupe par archivedAt). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId); void deleteByNotebookId(Long notebookId);
/** « Vider la conversation » : archive le fil actif en un lot horodaté. */
@Modifying
@Query("update NotebookMessageJpaEntity m set m.archivedAt = :now "
+ "where m.notebookId = :notebookId and m.archivedAt is null")
int archiveActiveMessages(@Param("notebookId") Long notebookId, @Param("now") LocalDateTime now);
} }

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> { public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<NpcJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> { public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<RandomTableJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -49,6 +49,13 @@ public class PostgresCharacterRepository implements CharacterRepository {
return jpaRepository.existsById(Long.parseLong(id)); 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) { private Character toDomainEntity(CharacterJpaEntity e) {
return Character.builder() return Character.builder()
.id(e.getId().toString()) .id(e.getId().toString())

View File

@@ -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();
}
}

View File

@@ -67,6 +67,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.npcTemplate(e.getNpcTemplate() != null .npcTemplate(e.getNpcTemplate() != null
? new java.util.ArrayList<>(e.getNpcTemplate()) ? new java.util.ArrayList<>(e.getNpcTemplate())
: new java.util.ArrayList<>()) : new java.util.ArrayList<>())
.enemyTemplate(e.getEnemyTemplate() != null
? new java.util.ArrayList<>(e.getEnemyTemplate())
: new java.util.ArrayList<>())
.author(e.getAuthor()) .author(e.getAuthor())
.isPublic(e.isPublic()) .isPublic(e.isPublic())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -87,6 +90,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.npcTemplate(g.getNpcTemplate() != null .npcTemplate(g.getNpcTemplate() != null
? new java.util.ArrayList<>(g.getNpcTemplate()) ? new java.util.ArrayList<>(g.getNpcTemplate())
: new java.util.ArrayList<>()) : new java.util.ArrayList<>())
.enemyTemplate(g.getEnemyTemplate() != null
? new java.util.ArrayList<>(g.getEnemyTemplate())
: new java.util.ArrayList<>())
.author(g.getAuthor()) .author(g.getAuthor())
.isPublic(g.isPublic()) .isPublic(g.isPublic())
.createdAt(g.getCreatedAt()) .createdAt(g.getCreatedAt())

View File

@@ -77,6 +77,14 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
return jpaRepository.existsById(Long.parseLong(id)); 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) { private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
List<CatalogItem> items = e.getItems().stream() List<CatalogItem> items = e.getItems().stream()
.map(c -> CatalogItem.builder() .map(c -> CatalogItem.builder()

View File

@@ -115,7 +115,19 @@ public class PostgresNotebookRepository implements NotebookRepository {
@Override @Override
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) { public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList());
}
@Override
@Transactional
public void archiveMessagesByNotebookId(String notebookId) {
messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now());
}
@Override
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList()); .map(this::toMessage).collect(Collectors.toList());
} }
@@ -150,6 +162,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
.role(e.getRole()) .role(e.getRole())
.content(e.getContent()) .content(e.getContent())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
.archivedAt(e.getArchivedAt())
.build(); .build();
} }
} }

View File

@@ -50,6 +50,13 @@ public class PostgresNpcRepository implements NpcRepository {
return jpaRepository.existsById(Long.parseLong(id)); 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) { private Npc toDomainEntity(NpcJpaEntity e) {
return Npc.builder() return Npc.builder()
.id(e.getId().toString()) .id(e.getId().toString())

View File

@@ -81,6 +81,14 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
return jpaRepository.existsById(Long.parseLong(id)); 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) { private RandomTable toDomainEntity(RandomTableJpaEntity e) {
List<RandomTableEntry> entries = e.getEntries().stream() List<RandomTableEntry> entries = e.getEntries().stream()
.map(c -> RandomTableEntry.builder() .map(c -> RandomTableEntry.builder()

View File

@@ -80,6 +80,9 @@ public class PostgresSceneRepository implements SceneRepository {
.choicesConsequences(jpaEntity.getChoicesConsequences()) .choicesConsequences(jpaEntity.getChoicesConsequences())
.combatDifficulty(jpaEntity.getCombatDifficulty()) .combatDifficulty(jpaEntity.getCombatDifficulty())
.enemies(jpaEntity.getEnemies()) .enemies(jpaEntity.getEnemies())
.enemyIds(jpaEntity.getEnemyIds() != null
? new ArrayList<>(jpaEntity.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(jpaEntity.getRelatedPageIds() != null .relatedPageIds(jpaEntity.getRelatedPageIds() != null
? new ArrayList<>(jpaEntity.getRelatedPageIds()) ? new ArrayList<>(jpaEntity.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())
@@ -117,6 +120,9 @@ public class PostgresSceneRepository implements SceneRepository {
.choicesConsequences(scene.getChoicesConsequences()) .choicesConsequences(scene.getChoicesConsequences())
.combatDifficulty(scene.getCombatDifficulty()) .combatDifficulty(scene.getCombatDifficulty())
.enemies(scene.getEnemies()) .enemies(scene.getEnemies())
.enemyIds(scene.getEnemyIds() != null
? new ArrayList<>(scene.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(scene.getRelatedPageIds() != null .relatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds()) ? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())

View File

@@ -16,10 +16,13 @@ public class CharacterController {
private final CharacterService characterService; private final CharacterService characterService;
private final CharacterMapper characterMapper; 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.characterService = characterService;
this.characterMapper = characterMapper; this.characterMapper = characterMapper;
this.playthroughRepository = playthroughRepository;
} }
@PostMapping @PostMapping
@@ -43,6 +46,31 @@ public class CharacterController {
return ResponseEntity.ok(dtos); 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}") @PutMapping("/{id}")
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) { public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder())); Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));

View File

@@ -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) {}
}

View File

@@ -263,6 +263,7 @@ public class GameSystemController {
dto.getRulesMarkdown(), dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()), toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()), toDomainFields(dto.getNpcTemplate()),
toDomainFields(dto.getEnemyTemplate()),
dto.getAuthor(), dto.getAuthor(),
dto.isPublic() dto.isPublic()
); );

View File

@@ -58,6 +58,14 @@ public class ItemCatalogController {
return ResponseEntity.noContent().build(); 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. */ /** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate") @PostMapping("/generate")
public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) { public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) {

View File

@@ -105,6 +105,43 @@ public class NotebookController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
// --- Conversation : vider (= archiver) et consulter les archives ---
/**
* « Vider la conversation » : le fil actif est ARCHIVÉ en un lot horodaté,
* jamais supprimé — consultable ensuite via {@link #listArchives}.
*/
@PostMapping("/{id}/chat/clear")
public ResponseEntity<Void> clearChat(@PathVariable String id) {
if (service.getNotebook(id).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable");
}
service.clearChat(id);
return ResponseEntity.noContent().build();
}
/** Archives de conversation, plus récentes d'abord : [{archivedAt, messages:[…]}]. */
@GetMapping("/{id}/chat/archives")
public ResponseEntity<List<Map<String, Object>>> listArchives(@PathVariable String id) {
var grouped = new java.util.TreeMap<java.time.LocalDateTime, List<Map<String, Object>>>(
java.util.Comparator.reverseOrder());
for (var m : service.getArchivedMessages(id)) {
grouped.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>())
.add(Map.of(
"role", m.getRole(),
"content", m.getContent(),
"createdAt", m.getCreatedAt().toString()));
}
List<Map<String, Object>> out = new java.util.ArrayList<>();
grouped.forEach((archivedAt, messages) -> {
Map<String, Object> archive = new LinkedHashMap<>();
archive.put("archivedAt", archivedAt.toString());
archive.put("messages", messages);
out.add(archive);
});
return ResponseEntity.ok(out);
}
// --- Chat ancré streamé --- // --- Chat ancré streamé ---
@PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@@ -124,8 +161,26 @@ public class NotebookController {
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream() List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent())) .map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
.toList(); .toList();
List<String> sourceIds = service.readySourceIds(id); // Sélection de l'UI (cases cochées) : on ne garde que les sources qui
String context = service.buildContext(nb.getCampaignId()); // appartiennent bien à CE notebook et sont prêtes — un id étranger est
// ignoré. Limite le coût (ex. analyse approfondie sur 1 PDF au lieu de 5).
// Variable finale : elle est capturée par la lambda du taskExecutor.
List<String> readyIds = service.readySourceIds(id);
final List<String> sourceIds;
if (req.sourceIds() != null) {
var wanted = new java.util.HashSet<>(req.sourceIds());
sourceIds = readyIds.stream().filter(wanted::contains).toList();
} else {
sourceIds = readyIds;
}
// Contexte = brief de campagne + archives cochées en référence (le tout
// dans une variable finale : capturée par la lambda du taskExecutor).
String campaignContext = service.buildContext(nb.getCampaignId());
String archiveContext = service.buildArchiveContext(id, req.archiveIds());
final String context = archiveContext.isEmpty()
? campaignContext
: (campaignContext.isEmpty() ? archiveContext
: campaignContext + "\n\n" + archiveContext);
boolean deep = req.deep() != null && req.deep(); boolean deep = req.deep() != null && req.deep();
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
@@ -220,5 +275,14 @@ public class NotebookController {
public record CreateRequest(String campaignId, String name) {} public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {} public record RenameRequest(String name) {}
public record ChatRequest(String message, Boolean deep) {} /**
* @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour
* (cases cochées dans l'UI). Null = toutes les sources prêtes.
* Toujours intersecté avec les sources du notebook (sécurité).
* @param archiveIds Optionnel : archives de conversation cochées comme RÉFÉRENCE
* (clés = archivedAt). Leur contenu est injecté dans le contexte
* du prompt — toujours résolu dans CE notebook (sécurité).
*/
public record ChatRequest(String message, Boolean deep, List<String> sourceIds,
List<String> archiveIds) {}
} }

View File

@@ -43,6 +43,15 @@ public class NpcController {
return ResponseEntity.ok(dtos); 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. */ /** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
@GetMapping("/lore/{loreId}") @GetMapping("/lore/{loreId}")
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) { public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {

View File

@@ -59,6 +59,14 @@ public class RandomTableController {
return ResponseEntity.noContent().build(); 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. */ /** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate") @PostMapping("/generate")
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) { public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {

View File

@@ -16,6 +16,8 @@ public class RoomDTO {
private String name; private String name;
private String description; private String description;
private String enemies; 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 loot;
private String traps; private String traps;
private String gmNotes; private String gmNotes;

View File

@@ -30,6 +30,9 @@ public class SceneDTO {
private String combatDifficulty; private String combatDifficulty;
private String enemies; 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). */ /** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();

View File

@@ -19,6 +19,7 @@ public class GameSystemDTO {
private String rulesMarkdown; private String rulesMarkdown;
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>(); private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>(); private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
private List<TemplateFieldDTO> enemyTemplate = new ArrayList<>();
private String author; private String author;
private boolean isPublic; private boolean isPublic;
} }

View File

@@ -27,6 +27,7 @@ public class GameSystemMapper {
dto.setRulesMarkdown(g.getRulesMarkdown()); dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate())); dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(toDTOList(g.getNpcTemplate())); dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
dto.setAuthor(g.getAuthor()); dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic()); dto.setPublic(g.isPublic());
return dto; return dto;
@@ -41,6 +42,7 @@ public class GameSystemMapper {
.rulesMarkdown(dto.getRulesMarkdown()) .rulesMarkdown(dto.getRulesMarkdown())
.characterTemplate(toDomainList(dto.getCharacterTemplate())) .characterTemplate(toDomainList(dto.getCharacterTemplate()))
.npcTemplate(toDomainList(dto.getNpcTemplate())) .npcTemplate(toDomainList(dto.getNpcTemplate()))
.enemyTemplate(toDomainList(dto.getEnemyTemplate()))
.author(dto.getAuthor()) .author(dto.getAuthor())
.isPublic(dto.isPublic()) .isPublic(dto.isPublic())
.build(); .build();

View File

@@ -40,6 +40,9 @@ public class SceneMapper {
dto.setChoicesConsequences(scene.getChoicesConsequences()); dto.setChoicesConsequences(scene.getChoicesConsequences());
dto.setCombatDifficulty(scene.getCombatDifficulty()); dto.setCombatDifficulty(scene.getCombatDifficulty());
dto.setEnemies(scene.getEnemies()); dto.setEnemies(scene.getEnemies());
dto.setEnemyIds(scene.getEnemyIds() != null
? new ArrayList<>(scene.getEnemyIds())
: new ArrayList<>());
dto.setRelatedPageIds(scene.getRelatedPageIds() != null dto.setRelatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds()) ? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>()); : new ArrayList<>());
@@ -74,6 +77,9 @@ public class SceneMapper {
.choicesConsequences(dto.getChoicesConsequences()) .choicesConsequences(dto.getChoicesConsequences())
.combatDifficulty(dto.getCombatDifficulty()) .combatDifficulty(dto.getCombatDifficulty())
.enemies(dto.getEnemies()) .enemies(dto.getEnemies())
.enemyIds(dto.getEnemyIds() != null
? new ArrayList<>(dto.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(dto.getRelatedPageIds() != null .relatedPageIds(dto.getRelatedPageIds() != null
? new ArrayList<>(dto.getRelatedPageIds()) ? new ArrayList<>(dto.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())
@@ -117,6 +123,9 @@ public class SceneMapper {
dto.setName(r.getName()); dto.setName(r.getName());
dto.setDescription(r.getDescription()); dto.setDescription(r.getDescription());
dto.setEnemies(r.getEnemies()); dto.setEnemies(r.getEnemies());
dto.setEnemyIds(r.getEnemyIds() != null
? new ArrayList<>(r.getEnemyIds())
: new ArrayList<>());
dto.setLoot(r.getLoot()); dto.setLoot(r.getLoot());
dto.setTraps(r.getTraps()); dto.setTraps(r.getTraps());
dto.setGmNotes(r.getGmNotes()); dto.setGmNotes(r.getGmNotes());
@@ -145,6 +154,9 @@ public class SceneMapper {
.name(d.getName()) .name(d.getName())
.description(d.getDescription()) .description(d.getDescription())
.enemies(d.getEnemies()) .enemies(d.getEnemies())
.enemyIds(d.getEnemyIds() != null
? new ArrayList<>(d.getEnemyIds())
: new ArrayList<>())
.loot(d.getLoot()) .loot(d.getLoot())
.traps(d.getTraps()) .traps(d.getTraps())
.gmNotes(d.getGmNotes()) .gmNotes(d.getGmNotes())

View File

@@ -37,6 +37,7 @@ class CampaignStructuralContextTest {
ArcSummary arc = new ArcSummary( ArcSummary arc = new ArcSummary(
"Acte I", "Acte I",
"Mise en place", "Mise en place",
false,
1, 1,
List.of(chapter)); List.of(chapter));
@@ -76,7 +77,7 @@ class CampaignStructuralContextTest {
@Test @Test
void illustrationCount_defaultsToZero_onAllSummaryTypes() { 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()); ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of()); SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of());
@@ -92,6 +93,7 @@ class CampaignStructuralContextTest {
ArcSummary arc = new ArcSummary( ArcSummary arc = new ArcSummary(
"Acte I", "Acte I",
null, null,
false,
0, 0,
List.of( List.of(
new ChapterSummary("Ch1", null, 0, List.of()), new ChapterSummary("Ch1", null, 0, List.of()),

View File

@@ -165,7 +165,7 @@ class BrainChatPayloadBuilderTest {
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%"); BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of()); SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene)); 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( CampaignStructuralContext camp = new CampaignStructuralContext(
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of()); "Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -198,7 +198,7 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_arcSummary_omitsIllustrationCount_whenZero() { 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( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -215,7 +215,7 @@ class BrainChatPayloadBuilderTest {
void build_sceneSummary_omitsBranches_whenEmpty() { void build_sceneSummary_omitsBranches_whenEmpty() {
SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of()); SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); 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( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -234,7 +234,7 @@ class BrainChatPayloadBuilderTest {
BranchHint branch = new BranchHint("X", "Y", " "); BranchHint branch = new BranchHint("X", "Y", " ");
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of()); SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); 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( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.4-beta", "version": "0.13.0-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.4-beta", "version": "0.13.0-beta",
"dependencies": { "dependencies": {
"@angular/animations": "^21.2.16", "@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16", "@angular/common": "^21.2.16",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.4-beta", "version": "0.13.0-beta",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -23,6 +23,7 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
{ path: 'campaigns/:campaignId/npcs', loadComponent: () => import('./campaigns/npc/npc-list/npc-list.component').then(m => m.NpcListComponent) },
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
@@ -32,6 +33,10 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) }, { path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) }, { path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) }, { path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) },
{ path: 'campaigns/:campaignId/enemies', loadComponent: () => import('./campaigns/enemy/enemy-list/enemy-list.component').then(m => m.EnemyListComponent) },
{ path: 'campaigns/:campaignId/enemies/create', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) },
{ path: 'campaigns/:campaignId/enemies/:enemyId/edit', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) },
{ path: 'campaigns/:campaignId/enemies/:enemyId', loadComponent: () => import('./campaigns/enemy/enemy-view/enemy-view.component').then(m => m.EnemyViewComponent) },
{ path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, { path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, { path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) }, { path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) },

View File

@@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -41,6 +42,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private layoutService: LayoutService private layoutService: LayoutService
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
@@ -60,7 +62,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length; this.existingArcCount = treeData.arcs.length;

View File

@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -78,6 +79,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -117,7 +119,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId), arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -60,6 +61,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -83,7 +85,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId), arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -4,11 +4,13 @@ import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service'; import { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service'; import { NpcService } from '../services/npc.service';
import { RandomTableService } from '../services/random-table.service'; import { RandomTableService } from '../services/random-table.service';
import { EnemyService } from '../services/enemy.service';
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service'; import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model'; import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
import { Character } from '../services/character.model'; import { Character } from '../services/character.model';
import { Npc } from '../services/npc.model'; import { Npc } from '../services/npc.model';
import { RandomTable } from '../services/random-table.model'; import { RandomTable } from '../services/random-table.model';
import { Enemy } from '../services/enemy.model';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
/** /**
@@ -26,6 +28,7 @@ export interface CampaignTreeData {
characters: Character[]; characters: Character[];
npcs: Npc[]; npcs: Npc[];
randomTables: RandomTable[]; randomTables: RandomTable[];
enemies: Enemy[];
} }
export function loadCampaignTreeData( export function loadCampaignTreeData(
@@ -35,7 +38,10 @@ export function loadCampaignTreeData(
npcService: NpcService, npcService: NpcService,
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les // Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
// tables aléatoires sont chargées et apparaissent dans la sidebar. // tables aléatoires sont chargées et apparaissent dans la sidebar.
randomTableService?: RandomTableService randomTableService?: RandomTableService,
// Optionnel (même principe) : si fourni, les ennemis sont chargés et le nœud
// « Ennemis » devient dépliable (dossiers → fiches) en plus du lien.
enemyService?: EnemyService
): Observable<CampaignTreeData> { ): Observable<CampaignTreeData> {
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie, // Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent // pas à la campagne — on ne les charge plus ici (les vues qui les affichent
@@ -46,11 +52,14 @@ export function loadCampaignTreeData(
npcs: npcService.getByCampaign(campaignId), npcs: npcService.getByCampaign(campaignId),
randomTables: randomTableService randomTables: randomTableService
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[]))) ? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
: of([] as RandomTable[]) : of([] as RandomTable[]),
enemies: enemyService
? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[])))
: of([] as Enemy[])
}).pipe( }).pipe(
switchMap(({ arcs, characters, npcs, randomTables }) => { switchMap(({ arcs, characters, npcs, randomTables, enemies }) => {
if (arcs.length === 0) { if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables }); return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables, enemies });
} }
const chapterCalls = arcs.map(a => const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters }))) service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
@@ -65,7 +74,7 @@ export function loadCampaignTreeData(
}); });
if (allChapters.length === 0) { if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables }); return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables, enemies });
} }
const sceneCalls = allChapters.map(c => const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes }))) service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
@@ -74,7 +83,7 @@ export function loadCampaignTreeData(
map(sceneResults => { map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {}; const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; }); sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables }; return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables, enemies };
}) })
); );
}) })
@@ -135,6 +144,9 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
iconKey: 'c-drama', iconKey: 'c-drama',
children: npcChildren, children: npcChildren,
meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined, meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined,
// Cliquer le LIBELLÉ ouvre la page de liste (vue d'ensemble par dossiers) ;
// cliquer le CHEVRON déplie l'arbre dans la sidebar — les deux coexistent.
route: `/campaigns/${campaignId}/npcs`,
// Porte le header de section "Personnages" (les PJ ayant migré vers la Partie). // Porte le header de section "Personnages" (les PJ ayant migré vers la Partie).
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar. // Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
sectionHeaderBefore: 'Personnages', sectionHeaderBefore: 'Personnages',
@@ -146,6 +158,40 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
}] }]
}; };
// --- Ennemis (bestiaire) : même structure que les PNJ — dossiers dépliables
// dans la sidebar + libellé cliquable vers la page de liste.
const sortedEnemies = [...(data.enemies ?? [])].sort(byName);
const enemyItem = (e: Enemy): TreeItem => ({
id: `enemy-${e.id}`,
label: e.name,
route: `/campaigns/${campaignId}/enemies/${e.id}`,
meta: e.level ? `Niv. ${e.level}` : undefined
});
const enemiesByFolder = new Map<string, Enemy[]>();
const ungroupedEnemies: Enemy[] = [];
for (const e of sortedEnemies) {
const f = (e.folder ?? '').trim();
if (f) {
if (!enemiesByFolder.has(f)) enemiesByFolder.set(f, []);
enemiesByFolder.get(f)!.push(e);
} else {
ungroupedEnemies.push(e);
}
}
const enemyFolderNodes: TreeItem[] = [...enemiesByFolder.keys()]
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
.map(folder => {
const items = enemiesByFolder.get(folder)!.map(enemyItem);
return {
id: `enemy-folder-${folder}`,
label: folder,
iconKey: 'folder',
children: items,
meta: String(items.length)
};
});
const enemyChildren: TreeItem[] = [...enemyFolderNodes, ...ungroupedEnemies.map(enemyItem)];
const sortedArcs = [...data.arcs].sort(byName); const sortedArcs = [...data.arcs].sort(byName);
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => { const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
@@ -233,6 +279,24 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/item-catalogs` route: `/campaigns/${campaignId}/item-catalogs`
}; };
// Ennemis (bestiaire, fiches pilotées par le template Ennemi du GameSystem,
// classées par dossier) — rangé avec les PERSONNAGES, comme les PNJ.
// Libellé → page de liste ; chevron → arbre dépliable (dossiers → fiches).
const enemiesNode: TreeItem = {
id: 'enemies-root',
label: 'Ennemis',
iconKey: 'skull',
children: enemyChildren,
meta: sortedEnemies.length ? String(sortedEnemies.length) : undefined,
route: `/campaigns/${campaignId}/enemies`,
createActions: [{
id: 'new-enemy',
label: 'Nouvel ennemi',
route: `/campaigns/${campaignId}/enemies/create`,
actionIcon: 'plus'
}]
};
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers). // Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
const importNode: TreeItem = { const importNode: TreeItem = {
id: 'import-pdf-root', id: 'import-pdf-root',
@@ -241,7 +305,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/import` route: `/campaigns/${campaignId}/import`
}; };
return [...arcNodes, npcsNode, tablesNode, notebooksNode, catalogsNode, importNode]; return [...arcNodes, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode];
} }
/** /**

View File

@@ -13,6 +13,7 @@ import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { SessionService } from '../../../services/session.service'; import { SessionService } from '../../../services/session.service';
import { PlaythroughService } from '../../../services/playthrough.service'; import { PlaythroughService } from '../../../services/playthrough.service';
import { Playthrough } from '../../../services/campaign.model'; import { Playthrough } from '../../../services/campaign.model';
@@ -95,6 +96,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private sessionService: SessionService, private sessionService: SessionService,
private playthroughService: PlaythroughService, private playthroughService: PlaythroughService,
private layoutService: LayoutService, private layoutService: LayoutService,
@@ -112,8 +114,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
switchMap(id => forkJoin({ switchMap(id => forkJoin({
campaign: this.campaignService.getCampaignById(id), campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe( treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData)) catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
), ),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
})) }))
@@ -149,8 +151,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(id), campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe( treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData)) catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
), ),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => { }).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {

View File

@@ -11,6 +11,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model'; import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model';
@@ -106,6 +107,7 @@ export class CampaignImportComponent implements OnInit {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private campaignSidebar: CampaignSidebarService, private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService private pageTitle: PageTitleService
) {} ) {}
@@ -117,7 +119,7 @@ export class CampaignImportComponent implements OnInit {
// Pré-chargement de l'arborescence existante (pour fusionner à la revue). // Pré-chargement de l'arborescence existante (pour fusionner à la revue).
// En cas d'échec on dégrade : tout sera considéré comme nouveau. // En cas d'échec on dégrade : tout sera considéré comme nouveau.
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
.pipe(catchError(() => of(null))) .pipe(catchError(() => of(null)))
.subscribe(data => this.existingData = data); .subscribe(data => this.existingData = data);
} }

View File

@@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -43,6 +44,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private layoutService: LayoutService private layoutService: LayoutService
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
@@ -61,7 +63,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId); const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? ''; this.arcName = currentArc?.name ?? '';

View File

@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -92,6 +93,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -128,7 +130,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -7,6 +7,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service'; import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../../services/campaign.model'; import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
@@ -71,6 +72,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService private pageTitleService: PageTitleService
) {} ) {}
@@ -90,7 +92,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId), scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => { }).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter; this.chapter = chapter;
this.scenes = scenes; this.scenes = scenes;

View File

@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -52,6 +53,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -79,7 +81,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -0,0 +1,111 @@
<div class="ne-page">
<div class="ne-header">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour aux ennemis
</button>
<div class="header-row">
<h1>
<lucide-icon [img]="Skull" [size]="22"></lucide-icon>
{{ enemyId ? 'Éditer l\'ennemi' : 'Nouvel ennemi' }}
</h1>
</div>
</div>
<div class="ne-form">
<div class="field">
<label for="enemy-name">Nom de l'ennemi *</label>
<input
id="enemy-name"
type="text"
[(ngModel)]="name"
name="name"
placeholder="Ex: Balor, Gobelin chef de guerre, Liche…"
/>
</div>
<div class="field-row image-row">
<div class="field">
<label for="enemy-level">Niveau / FP</label>
<input
id="enemy-level"
type="text"
[(ngModel)]="level"
name="level"
placeholder="Ex: 5, FP 8, Boss…"
/>
</div>
<div class="field">
<label for="enemy-folder">Dossier</label>
<input
id="enemy-folder"
type="text"
[(ngModel)]="folder"
name="folder"
list="enemy-folders"
placeholder="Ex: Démons, Humanoïdes… (vide = non classé)"
/>
<datalist id="enemy-folders">
@for (f of existingFolders; track f) {
<option [value]="f"></option>
}
</datalist>
</div>
</div>
<div class="field-row image-row">
<div class="field portrait-field">
<label>Portrait</label>
<app-single-image-picker
[imageId]="portraitImageId"
aspectRatio="1 / 1"
hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
</div>
<div class="field header-field">
<label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ enemyId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
@if (enemyId) {
<button
type="button"
class="btn-danger"
(click)="deleteEnemy()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,157 @@
.ne-page {
padding: 2rem 3rem;
color: #e5e7eb;
max-width: 1000px;
margin: 0 auto;
}
.ne-header {
margin-bottom: 2rem;
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
h1 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.75rem;
color: white;
margin: 0.75rem 0 0;
}
}
.btn-ai {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: rgba(167, 139, 250, 0.08);
border: 1px solid rgba(167, 139, 250, 0.4);
color: #a78bfa;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.15s;
&:hover { background: rgba(167, 139, 250, 0.15); border-color: #a78bfa; }
&.active { background: #a78bfa; color: #0b1220; }
}
.btn-back {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0;
font-size: 0.85rem;
&:hover { color: #e5e7eb; }
}
.ne-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
label {
color: #e5e7eb;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.4rem;
}
.hint {
color: #6b7280;
font-size: 0.8rem;
margin: 0.4rem 0 0.5rem;
}
input[type="text"], textarea {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
color: #e5e7eb;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #a78bfa;
}
}
}
.content-field textarea {
font-family: 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.85rem;
line-height: 1.5;
resize: vertical;
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
align-items: center;
.spacer { flex: 1; }
}
.btn-primary {
background: #a78bfa;
color: #0b1220;
border: none;
padding: 0.6rem 1.25rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
&:disabled { opacity: 0.5; cursor: not-allowed; }
&:hover:not(:disabled) { background: #c4b5fd; }
}
.btn-secondary {
background: transparent;
border: 1px solid #1f2937;
color: #9ca3af;
padding: 0.6rem 1.25rem;
border-radius: 8px;
cursor: pointer;
&:hover { border-color: #374151; color: #e5e7eb; }
}
.btn-danger {
background: transparent;
border: 1px solid rgba(248, 113, 113, 0.3);
color: #f87171;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
&:hover {
border-color: #f87171;
background: rgba(248, 113, 113, 0.08);
}
}

View File

@@ -0,0 +1,167 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Skull, Trash2 } from 'lucide-angular';
import { EnemyService } from '../../../services/enemy.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Editeur plein écran d'une fiche d'ennemi (bestiaire). Même principe que
* NpcEditComponent : formulaire dynamique piloté par le template ENNEMI du
* GameSystem associé à la campagne, + champs universels niveau/dossier.
*/
@Component({
selector: 'app-enemy-edit',
imports: [FormsModule, LucideAngularModule, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './enemy-edit.component.html',
styleUrls: ['./enemy-edit.component.scss']
})
export class EnemyEditComponent implements OnInit {
readonly Save = Save;
readonly ArrowLeft = ArrowLeft;
readonly Skull = Skull;
readonly Trash2 = Trash2;
campaignId: string | null = null;
enemyId: string | null = null;
name = '';
level = '';
folder = '';
/** Dossiers déjà utilisés dans la campagne (datalist d'auto-complétion). */
existingFolders: string[] = [];
portraitImageId: string | null = null;
headerImageId: string | null = null;
values: Record<string, string> = {};
imageValues: Record<string, string[]> = {};
keyValueValues: Record<string, Record<string, string>> = {};
templateFields: TemplateField[] = [];
private order = 0;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: EnemyService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.enemyId = params.get('enemyId');
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
this.loadExistingFolders(this.campaignId);
}
if (this.enemyId) {
this.service.getById(this.enemyId).subscribe({
next: (e) => {
this.name = e.name;
this.level = e.level ?? '';
this.folder = e.folder ?? '';
this.portraitImageId = e.portraitImageId ?? null;
this.headerImageId = e.headerImageId ?? null;
this.values = e.values ?? {};
this.imageValues = e.imageValues ?? {};
this.keyValueValues = e.keyValueValues ?? {};
this.order = e.order ?? 0;
},
error: () => this.back()
});
}
}
private loadExistingFolders(campaignId: string): void {
this.service.getByCampaign(campaignId).subscribe({
next: (list) => {
this.existingFolders = [...new Set(
list.map(e => (e.folder ?? '').trim()).filter(f => f.length > 0)
)].sort((a, b) => a.localeCompare(b, 'fr'));
},
error: () => { this.existingFolders = []; }
});
}
private loadTemplateForCampaign(campaignId: string): void {
this.campaignService.getCampaignById(campaignId).subscribe({
next: (campaign) => {
if (!campaign.gameSystemId) {
this.templateFields = [];
return;
}
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
next: (gs) => { this.templateFields = gs.enemyTemplate ?? []; },
error: () => { this.templateFields = []; }
});
},
error: () => { this.templateFields = []; }
});
}
submit(): void {
if (!this.name.trim() || !this.campaignId) return;
const payload = {
name: this.name.trim(),
level: this.level.trim() || null,
folder: this.folder.trim() || null,
portraitImageId: this.portraitImageId,
headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const isCreation = !this.enemyId;
const req = this.enemyId
? this.service.update(this.enemyId, { ...payload, id: this.enemyId, order: this.order })
: this.service.create(payload);
req.subscribe({
next: (saved) => {
if (isCreation && saved.id) {
this.router.navigate(['/campaigns', this.campaignId, 'enemies', saved.id]);
} else {
this.back();
}
},
error: () => console.error('Erreur sauvegarde Enemy')
});
}
deleteEnemy(): void {
if (!this.enemyId) return;
this.confirmDialog.confirm({
title: 'Supprimer la fiche ?',
message: `Supprimer la fiche de "${this.name}" ?`,
details: ['Cette action est irreversible.'],
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok || !this.enemyId) return;
this.service.delete(this.enemyId).subscribe({
next: () => this.back(),
error: () => console.error('Erreur suppression Enemy')
});
});
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId, 'enemies']);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -0,0 +1,48 @@
<div class="sbl-page">
<div class="sbl-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-create" (click)="create()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouvel ennemi
</button>
</div>
<header class="sbl-header">
<h1><lucide-icon [img]="Skull" [size]="22"></lucide-icon> Ennemis</h1>
<p class="sbl-hint">
Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le
template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…).
</p>
</header>
<div class="sbl-list">
@if (total === 0) {
<p class="empty">Aucun ennemi pour l'instant.</p>
}
@for (g of groups; track g.folder) {
@if (g.folder) {
<h2 class="sbl-folder">
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
{{ g.folder }}
<span class="sbl-folder-count">{{ g.enemies.length }}</span>
</h2>
} @else if (groups.length > 1) {
<h2 class="sbl-folder sbl-folder--none">Non classés</h2>
}
@for (e of g.enemies; track e.id) {
<button class="sbl-item" (click)="open(e)">
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
<span class="sbl-item-name">{{ e.name }}</span>
@if (e.level) {
<span class="sbl-item-level">Niv. {{ e.level }}</span>
}
<span class="sbl-del" (click)="remove(e, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
}
</div>
</div>

View File

@@ -0,0 +1,53 @@
.sbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.sbl-toolbar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.8rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.btn-create { background: #667eea; border-color: #667eea; color: #fff; }
.btn-create:hover { background: #5568d3; }
}
.sbl-header {
margin-bottom: 1.25rem;
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
.sbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
}
.sbl-list { display: flex; flex-direction: column; gap: 0.4rem; }
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
.sbl-folder {
display: flex; align-items: center; gap: 0.4rem;
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em;
color: #a5b4fc;
.sbl-folder-count {
font-weight: 400; color: var(--color-text-muted, #9aa0aa);
}
&.sbl-folder--none { color: var(--color-text-muted, #9aa0aa); }
}
.sbl-item {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 0.85rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.07); }
.sbl-item-name { flex: 1; font-weight: 500; }
.sbl-item-level {
font-size: 0.74rem; padding: 0.12rem 0.5rem; border-radius: 999px;
background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.35); color: #fca5a5;
}
.sbl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
&:hover { background: rgba(224,90,90,0.15); } }
}

View File

@@ -0,0 +1,107 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder } from 'lucide-angular';
import { EnemyService } from '../../../services/enemy.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { Enemy } from '../../../services/enemy.model';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */
interface FolderGroup {
folder: string;
enemies: Enemy[];
}
/**
* Liste des ennemis d'une campagne, groupés par dossier (comme les PNJ).
* Route : /campaigns/:campaignId/enemies
*/
@Component({
selector: 'app-enemy-list',
imports: [LucideAngularModule],
templateUrl: './enemy-list.component.html',
styleUrls: ['./enemy-list.component.scss']
})
export class EnemyListComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Skull = Skull;
readonly Folder = Folder;
campaignId = '';
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
groups: FolderGroup[] = [];
total = 0;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: EnemyService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.load();
}
}
load(): void {
this.service.getByCampaign(this.campaignId).subscribe({
next: (list) => this.groups = this.groupByFolder(list),
error: () => this.groups = []
});
}
/** Même logique de groupement que les PNJ de la sidebar : dossiers triés, non-classés à la fin. */
private groupByFolder(enemies: Enemy[]): FolderGroup[] {
this.total = enemies.length;
const sorted = [...enemies].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
const byFolder = new Map<string, Enemy[]>();
const ungrouped: Enemy[] = [];
for (const e of sorted) {
const f = (e.folder ?? '').trim();
if (f) {
if (!byFolder.has(f)) byFolder.set(f, []);
byFolder.get(f)!.push(e);
} else {
ungrouped.push(e);
}
}
const groups: FolderGroup[] = [...byFolder.keys()]
.sort((a, b) => a.localeCompare(b, 'fr'))
.map(folder => ({ folder, enemies: byFolder.get(folder)! }));
if (ungrouped.length) groups.push({ folder: '', enemies: ungrouped });
return groups;
}
create(): void {
this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']);
}
open(e: Enemy): void {
this.router.navigate(['/campaigns', this.campaignId, 'enemies', e.id]);
}
remove(e: Enemy, ev: Event): void {
ev.stopPropagation();
this.confirmDialog.confirm({
title: 'Supprimer l\'ennemi',
message: `Supprimer « ${e.name} » ?`,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.service.delete(e.id!).subscribe(() => this.load());
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -0,0 +1,15 @@
<div class="nv-page">
<div class="nv-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
</button>
</div>
<app-persona-view [persona]="enemy" [templateFields]="templateFields" [subtitle]="subtitle"></app-persona-view>
</div>

View File

@@ -0,0 +1,94 @@
.nv-page {
padding: 16px 0 48px;
min-height: 100vh;
}
.nv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 16px;
max-width: 1100px;
margin: 0 auto;
.spacer { flex: 1; }
}
.btn-back,
.btn-edit,
.btn-ai {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
transition: all 120ms;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.btn-edit {
border-color: rgba(209, 168, 120, 0.4);
color: #d1a878;
&:hover {
background: rgba(209, 168, 120, 0.15);
}
}
.btn-ai.active {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
}
// Pages de Lore liées au PNJ — chips cliquables sous la fiche.
.nv-lore-links {
max-width: 1100px;
margin: 24px auto 0;
padding: 0 32px;
.nv-lore-links-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.72rem;
font-weight: 600;
color: #a5b4fc;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 0.6rem;
}
.nv-lore-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.nv-lore-chip {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.7rem;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 999px;
color: #d1d5db;
font-size: 0.82rem;
text-decoration: none;
transition: border-color 0.15s, color 0.15s;
&:hover {
border-color: #6c63ff;
color: white;
}
}
}

View File

@@ -0,0 +1,99 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { LucideAngularModule, ArrowLeft, Edit3 } from 'lucide-angular';
import { EnemyService } from '../../../services/enemy.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { Enemy } from '../../../services/enemy.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
/**
* Vue lecture seule d'une fiche d'ennemi (même rendu « persona » que les PNJ).
* Route : /campaigns/:campaignId/enemies/:enemyId
*/
@Component({
selector: 'app-enemy-view',
imports: [LucideAngularModule, PersonaViewComponent],
templateUrl: './enemy-view.component.html',
styleUrls: ['./enemy-view.component.scss']
})
export class EnemyViewComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
campaignId: string | null = null;
enemyId: string | null = null;
enemy: Enemy | null = null;
templateFields: TemplateField[] = [];
private paramsSub?: Subscription;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: EnemyService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
// paramMap (pas snapshot) : Angular réutilise le composant entre 2 ennemis.
this.paramsSub = this.route.paramMap.subscribe(params => {
const newCampaignId = params.get('campaignId');
this.enemyId = params.get('enemyId');
if (this.enemyId) {
this.service.getById(this.enemyId).subscribe({
next: e => { this.enemy = e; },
error: () => this.back()
});
}
if (newCampaignId && newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.campaignSidebar.show(this.campaignId);
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.enemyTemplate ?? [];
});
}
});
} else if (newCampaignId) {
this.campaignId = newCampaignId;
}
});
}
ngOnDestroy(): void {
this.paramsSub?.unsubscribe();
}
/** Sous-titre de la fiche : niveau + dossier (« Niveau 8 · Démons »). */
get subtitle(): string {
const parts: string[] = [];
if (this.enemy?.level) parts.push(`Niveau ${this.enemy.level}`);
if (this.enemy?.folder) parts.push(this.enemy.folder);
return parts.join(' · ');
}
edit(): void {
if (this.campaignId && this.enemyId) {
this.router.navigate(['/campaigns', this.campaignId, 'enemies', this.enemyId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId, 'enemies']);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -103,10 +103,19 @@ export class NotebookActionCardComponent implements OnInit {
} }
private createNpc(): void { private createNpc(): void {
// Valeurs de fiche proposées par l'IA (clés = champs du template PNJ) ;
// la description sert de repli si le modèle n'a pas détaillé par champ.
const values: Record<string, string> = {};
for (const [key, val] of Object.entries(this.action.values ?? {})) {
if (typeof val === 'string' && val.trim()) values[key] = val;
}
if (Object.keys(values).length === 0 && this.action.description) {
values['Description'] = this.action.description;
}
this.npcService.create({ this.npcService.create({
name: this.action.name, name: this.action.name,
campaignId: this.campaignId, campaignId: this.campaignId,
values: this.action.description ? { Description: this.action.description } : {} values
}).subscribe({ }).subscribe({
next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]), next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]),
error: (e) => this.fail(e) error: (e) => this.fail(e)
@@ -119,7 +128,12 @@ export class NotebookActionCardComponent implements OnInit {
description: this.action.description, description: this.action.description,
campaignId: this.campaignId, campaignId: this.campaignId,
order: this.arcs.length, order: this.arcs.length,
type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR' type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR',
themes: this.action.themes,
stakes: this.action.stakes,
rewards: this.action.rewards,
resolution: this.action.resolution,
gmNotes: this.action.gmNotes
}).subscribe({ }).subscribe({
next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]), next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]),
error: (e) => this.fail(e) error: (e) => this.fail(e)
@@ -132,7 +146,10 @@ export class NotebookActionCardComponent implements OnInit {
name: this.action.name, name: this.action.name,
description: this.action.description, description: this.action.description,
arcId: this.selectedArcId, arcId: this.selectedArcId,
order order,
playerObjectives: this.action.playerObjectives,
narrativeStakes: this.action.narrativeStakes,
gmNotes: this.action.gmNotes
}).subscribe({ }).subscribe({
next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]), next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]),
error: (e) => this.fail(e) error: (e) => this.fail(e)
@@ -143,9 +160,17 @@ export class NotebookActionCardComponent implements OnInit {
this.campaignService.createScene({ this.campaignService.createScene({
name: this.action.name, name: this.action.name,
description: this.action.description, description: this.action.description,
playerNarration: this.action.content, // `content` = ancien protocole (messages archivés d'avant l'enrichissement).
playerNarration: this.action.playerNarration ?? this.action.content,
chapterId: this.selectedChapterId, chapterId: this.selectedChapterId,
order: 0 order: 0,
location: this.action.location,
timing: this.action.timing,
atmosphere: this.action.atmosphere,
gmSecretNotes: this.action.gmSecretNotes,
choicesConsequences: this.action.choicesConsequences,
combatDifficulty: this.action.combatDifficulty,
enemies: this.action.enemies
}).subscribe({ }).subscribe({
next: (s) => this.done( next: (s) => this.done(
['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]), ['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]),

View File

@@ -20,13 +20,30 @@
@if (uploadError) { @if (uploadError) {
<p class="nbd-upload-error">{{ uploadError }}</p> <p class="nbd-upload-error">{{ uploadError }}</p>
} }
@if (readySourceCount > 1) {
<p class="nbd-sources-hint"
[class.partial]="selectedSourceIds.size < readySourceCount">
{{ selectedSourceIds.size }}/{{ readySourceCount }} source(s) utilisée(s) par le chat et l'analyse approfondie
</p>
}
@for (s of sources; track s) { @for (s of sources; track s) {
<div class="nbd-source"> <div class="nbd-source" [class.nbd-source--off]="s.status === 'READY' && !isSelected(s)">
@if (s.status === 'READY') {
<input
type="checkbox"
class="nbd-source-check"
[checked]="isSelected(s)"
(change)="toggleSource(s)"
[title]="isSelected(s)
? 'Source utilisée par le chat décocher pour l\'exclure'
: 'Source ignorée par le chat cocher pour l\'inclure'" />
} @else {
<lucide-icon <lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)" [img]="s.status === 'FAILED' ? AlertCircle : Loader"
[size]="14" [size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'"> [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon> </lucide-icon>
}
<div class="nbd-source-info"> <div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div> <div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta"> <div class="nbd-source-meta">
@@ -54,7 +71,88 @@
</aside> </aside>
<!-- Chat --> <!-- Chat -->
<section class="nbd-chat"> <section class="nbd-chat">
<!-- Barre d'actions du chat : archives + vider -->
<div class="nbd-chat-head">
@if (viewingArchive) {
<span class="nbd-archive-banner">
<lucide-icon [img]="History" [size]="13"></lucide-icon>
Archive du {{ archiveLabel(viewingArchive) }} — lecture seule
</span>
<button type="button" class="nbd-chat-btn" (click)="closeArchive()">
<lucide-icon [img]="X" [size]="13"></lucide-icon>
Revenir au chat
</button>
} @else {
@if (referencedArchiveIds.size > 0) {
<span class="nbd-ref-badge"
title="Le contenu de ces archives est injecté comme référence dans chaque question">
<lucide-icon [img]="History" [size]="12"></lucide-icon>
{{ referencedArchiveIds.size }} archive(s) en référence
</span>
}
<span class="nbd-chat-head-spacer"></span>
<button type="button" class="nbd-chat-btn" (click)="toggleArchives()"
[class.active]="archivesOpen"
title="Conversations archivées (lecture seule + référence)">
<lucide-icon [img]="History" [size]="13"></lucide-icon>
Archives
</button>
<button type="button" class="nbd-chat-btn nbd-chat-btn--danger" (click)="clearChat()"
[disabled]="sending || messages.length === 0"
title="Vider la conversation (le fil actuel est archivé, pas supprimé)">
<lucide-icon [img]="Eraser" [size]="13"></lucide-icon>
Vider
</button>
}
</div>
<!-- Panneau des archives -->
@if (archivesOpen && !viewingArchive) {
<div class="nbd-archives">
@if (archives.length === 0) {
<p class="nbd-empty">Aucune conversation archivée pour l'instant — « Vider » archive le fil courant ici.</p>
} @else {
<p class="nbd-archives-help">
Cochez une archive pour l'utiliser comme <strong>référence</strong> dans le chat ;
cliquez sur son nom pour la relire.
</p>
}
@for (a of archives; track a.archivedAt) {
<div class="nbd-archive-row" [class.referenced]="isReferenced(a)">
<input
type="checkbox"
class="nbd-archive-check"
[checked]="isReferenced(a)"
(change)="toggleReference(a)"
[title]="isReferenced(a)
? 'Archive utilisée comme référence décocher pour la retirer'
: 'Utiliser cette archive comme référence dans le chat'" />
<button type="button" class="nbd-archive-item" (click)="openArchive(a)">
<lucide-icon [img]="History" [size]="13"></lucide-icon>
{{ archiveLabel(a) }}
</button>
</div>
}
</div>
}
<div class="nbd-messages"> <div class="nbd-messages">
@if (viewingArchive) {
<!-- Archive en lecture seule -->
@for (m of viewingArchive.messages; track $index) {
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
@if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
@if (m.role === 'assistant') {
<div class="nbd-msg-content md" [innerHTML]="m.content | markdown"></div>
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
}
} @else {
@if (messages.length === 0) { @if (messages.length === 0) {
<p class="nbd-empty"> <p class="nbd-empty">
Pose une question sur ta source, ou demande une adaptation pour ta campagne. Pose une question sur ta source, ou demande une adaptation pour ta campagne.
@@ -79,9 +177,11 @@
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }} Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div> </div>
} }
<div class="nbd-msg-content">{{ p.text }}@if (sending && !p.text && !p.actions.length && !deepProgress) { @if (p.text) {
<span class="cursor"></span> <div class="nbd-msg-content md" [innerHTML]="p.text | markdown"></div>
}</div> } @else if (sending && !p.actions.length && !deepProgress) {
<div class="nbd-msg-content"><span class="cursor"></span></div>
}
@for (a of p.actions; track $index) { @for (a of p.actions; track $index) {
<app-notebook-action-card <app-notebook-action-card
[action]="a" [action]="a"
@@ -102,7 +202,9 @@
} }
</div> </div>
} }
}
</div> </div>
@if (!viewingArchive) {
<div class="nbd-input"> <div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2" <textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…" placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
@@ -116,6 +218,7 @@
<lucide-icon [img]="Send" [size]="15"></lucide-icon> <lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button> </button>
</div> </div>
}
</section> </section>
</div> </div>
</div> </div>

View File

@@ -39,12 +39,36 @@
} }
.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; } .nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; }
// Compteur de sources actives — ambre quand une partie est décochée, pour
// rappeler que le chat ne voit pas tout.
.nbd-sources-hint {
font-size: 0.72rem;
color: var(--color-text-muted, #9aa0aa);
margin: 0.1rem 0 0.2rem;
&.partial { color: #fbbf24; }
}
.nbd-source { .nbd-source {
display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem;
border-radius: 7px; background: rgba(255,255,255,0.03); border-radius: 7px; background: rgba(255,255,255,0.03);
lucide-icon.ok { color: #6bd08a; } lucide-icon.ok { color: #6bd08a; }
lucide-icon.fail { color: #e88; } lucide-icon.fail { color: #e88; }
lucide-icon.busy { color: #e0c074; } lucide-icon.busy { color: #e0c074; }
// Case « source utilisée par le chat » (sources prêtes uniquement).
.nbd-source-check {
margin: 2px 0 0;
accent-color: #6bd08a;
cursor: pointer;
flex-shrink: 0;
}
// Source décochée : grisée = hors du périmètre du chat et de l'analyse.
&.nbd-source--off {
opacity: 0.5;
}
.nbd-source-info { flex: 1; min-width: 0; } .nbd-source-info { flex: 1; min-width: 0; }
.nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; } .nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; }
.nbd-source-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); } .nbd-source-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
@@ -57,8 +81,84 @@
/* Chat */ /* Chat */
.nbd-chat { .nbd-chat {
display: flex; flex-direction: column; min-height: 0; display: flex; flex-direction: column; min-height: 0;
// min-width: 0 indispensable : enfant de grille (colonne 1fr), sinon sa largeur
// MINIMALE devient celle de la plus longue ligne insécable d'un message
// (tableau markdown, longue URL…) et le chat pousse toute la page hors écran.
min-width: 0;
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
} }
// Barre d'actions du chat : archives + vider (ou bandeau « archive en lecture seule »).
.nbd-chat-head {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.08);
.nbd-chat-head-spacer { flex: 1; }
.nbd-archive-banner {
flex: 1;
display: inline-flex; align-items: center; gap: 0.35rem;
font-size: 0.8rem; color: #e0c074; font-style: italic;
}
// Badge « N archive(s) en référence » : rappelle que le contexte du chat
// inclut d'anciennes conversations, même panneau fermé.
.nbd-ref-badge {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.74rem;
background: rgba(168,130,255,0.12); border: 1px solid rgba(168,130,255,0.4);
color: #c4a8ff;
}
.nbd-chat-btn {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.28rem 0.55rem; border-radius: 6px; font-size: 0.78rem;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer;
&:hover:not(:disabled) { background: rgba(255,255,255,0.09); }
&.active { border-color: #667eea; }
&:disabled { opacity: 0.45; cursor: default; }
&--danger {
color: #e88; border-color: rgba(224,90,90,0.35);
&:hover:not(:disabled) { background: rgba(224,90,90,0.12); }
}
}
}
// Panneau des conversations archivées.
.nbd-archives {
display: flex; flex-direction: column; gap: 0.3rem;
padding: 0.5rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.08);
.nbd-archives-help {
margin: 0 0 0.2rem;
font-size: 0.74rem;
color: var(--color-text-muted, #9aa0aa);
}
.nbd-archive-row {
display: flex; align-items: center; gap: 0.45rem;
// Archive cochée en référence : léger liseré violet pour la repérer.
&.referenced .nbd-archive-item { border-color: rgba(168,130,255,0.5); }
}
.nbd-archive-check {
accent-color: #c4a8ff;
cursor: pointer;
flex-shrink: 0;
}
.nbd-archive-item {
flex: 1;
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.35rem 0.55rem; border-radius: 6px; font-size: 0.82rem;
border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.08); }
}
}
.nbd-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; } .nbd-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; }
.nbd-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } .nbd-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
@@ -69,7 +169,90 @@
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.2rem; color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.2rem;
} }
.nbd-msg-content { white-space: pre-wrap; line-height: 1.5; } // overflow-wrap: anywhere : autorise la coupure DANS une séquence insécable
// (ligne de tableau markdown, URL) plutôt que de déborder du conteneur.
.nbd-msg-content { white-space: pre-wrap; line-height: 1.5; overflow-wrap: anywhere; }
// Rendu markdown des messages assistant (même esthétique que ai-chat-drawer).
// white-space normal : marked génère des <p>/<li>/<br>, pre-wrap doublerait
// les espacements entre blocs.
.nbd-msg-content.md {
white-space: normal;
> :first-child { margin-top: 0; }
> :last-child { margin-bottom: 0; }
p { margin: 0 0 0.5em; }
p:last-child { margin-bottom: 0; }
h1, h2, h3, h4, h5, h6 {
margin: 0.75em 0 0.35em;
font-weight: 600;
color: #f3f4f6;
line-height: 1.25;
}
h1 { font-size: 1.05rem; }
h2 { font-size: 1rem; }
h3 { font-size: 0.95rem; }
h4, h5, h6 { font-size: 0.9rem; }
strong { color: #f3f4f6; font-weight: 600; }
em { color: #d1d5db; font-style: italic; }
ul, ol { margin: 0.35em 0 0.5em; padding-left: 1.4em; }
li { margin: 0.15em 0; }
ul ul, ul ol, ol ul, ol ol { margin: 0.15em 0; }
code {
background: #0b0b15;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0 0.25em;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 0.82em;
}
pre {
background: #0b0b15;
border: 1px solid #2a2a3d;
border-radius: 6px;
padding: 0.6em 0.75em;
margin: 0.5em 0;
overflow-x: auto;
font-size: 0.82em;
code { background: transparent; border: 0; padding: 0; font-size: inherit; }
}
blockquote {
margin: 0.4em 0;
padding: 0.2em 0.8em;
border-left: 3px solid #3a3a5a;
color: #9ca3af;
font-style: italic;
}
a {
color: #a5b4fc;
text-decoration: underline;
&:hover { color: #c7d2fe; }
}
hr { border: 0; border-top: 1px solid #2a2a3d; margin: 0.6em 0; }
// Les tableaux (inventaires de boutique…) peuvent être plus larges que la
// bulle : on les laisse défiler horizontalement DANS le message.
table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 0.4em 0;
font-size: 0.85em;
th, td { border: 1px solid #2a2a3d; padding: 0.3em 0.5em; white-space: nowrap; }
th { background: #14142a; font-weight: 600; }
}
}
&.user { align-self: flex-end; text-align: right; &.user { align-self: flex-end; text-align: right;
.nbd-msg-content { background: rgba(102,126,234,0.16); padding: 0.5rem 0.75rem; border-radius: 10px; display: inline-block; text-align: left; } .nbd-msg-content { background: rgba(102,126,234,0.16); padding: 0.5rem 0.75rem; border-radius: 10px; display: inline-block; text-align: left; }
} }

View File

@@ -2,17 +2,20 @@ import { Component, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers, Eraser, History, X } from 'lucide-angular';
import { NotebookService } from '../../../services/notebook.service'; import { NotebookService } from '../../../services/notebook.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model'; import { EnemyService } from '../../../services/enemy.service';
import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model';
import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model'; import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model';
import { Arc, Chapter } from '../../../services/campaign.model'; import { Arc, Chapter } from '../../../services/campaign.model';
import { loadCampaignTreeData } from '../../campaign-tree.helper'; import { loadCampaignTreeData } from '../../campaign-tree.helper';
import { NotebookActionCardComponent } from '../notebook-action-card/notebook-action-card.component'; import { NotebookActionCardComponent } from '../notebook-action-card/notebook-action-card.component';
import { MarkdownPipe } from '../../../shared/markdown.pipe';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/** /**
* Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite). * Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite).
@@ -20,7 +23,7 @@ import { NotebookActionCardComponent } from '../notebook-action-card/notebook-ac
*/ */
@Component({ @Component({
selector: 'app-notebook-detail', selector: 'app-notebook-detail',
imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent], imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent, MarkdownPipe],
templateUrl: './notebook-detail.component.html', templateUrl: './notebook-detail.component.html',
styleUrls: ['./notebook-detail.component.scss'] styleUrls: ['./notebook-detail.component.scss']
}) })
@@ -35,6 +38,9 @@ export class NotebookDetailComponent implements OnInit {
readonly AlertCircle = AlertCircle; readonly AlertCircle = AlertCircle;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Layers = Layers; readonly Layers = Layers;
readonly Eraser = Eraser;
readonly History = History;
readonly X = X;
campaignId = ''; campaignId = '';
notebookId = ''; notebookId = '';
@@ -60,7 +66,9 @@ export class NotebookDetailComponent implements OnInit {
private campaignSidebar: CampaignSidebarService, private campaignSidebar: CampaignSidebarService,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService private npcService: NpcService,
private enemyService: EnemyService,
private confirmDialog: ConfirmDialogService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -74,7 +82,7 @@ export class NotebookDetailComponent implements OnInit {
} }
private loadTree(): void { private loadTree(): void {
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService)
.subscribe({ .subscribe({
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; }, next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
error: () => { /* cibles indisponibles : les cartes le signaleront */ } error: () => { /* cibles indisponibles : les cartes le signaleront */ }
@@ -137,13 +145,54 @@ export class NotebookDetailComponent implements OnInit {
this.detail = d; this.detail = d;
this.sources = d.sources ?? []; this.sources = d.sources ?? [];
this.messages = d.messages ?? []; this.messages = d.messages ?? [];
this.syncSelection();
}, },
error: () => this.back() error: () => this.back()
}); });
} }
reloadSources(): void { reloadSources(): void {
this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] }); this.service.get(this.notebookId).subscribe({ next: (d) => {
this.sources = d.sources ?? [];
this.syncSelection();
} });
}
// --- Sélection des sources utilisées par le chat -------------------------
// Par défaut tout est coché ; décocher permet de limiter une question (et
// surtout une analyse approfondie, coûteuse en requêtes) à certains PDF.
/** IDs des sources cochées (sous-ensemble des sources READY). */
selectedSourceIds = new Set<string>();
/** IDs déjà vus — pour ne cocher par défaut que les NOUVELLES sources. */
private knownSourceIds = new Set<string>();
/** Aligne la sélection sur la liste courante : nouvelles sources cochées par
* défaut, sources supprimées retirées, choix de l'utilisateur préservés. */
private syncSelection(): void {
const readyIds = new Set(this.sources.filter(s => s.status === 'READY').map(s => s.id));
for (const id of readyIds) {
if (!this.knownSourceIds.has(id)) {
this.selectedSourceIds.add(id);
this.knownSourceIds.add(id);
}
}
for (const id of [...this.selectedSourceIds]) {
if (!readyIds.has(id)) this.selectedSourceIds.delete(id);
}
}
isSelected(s: NotebookSource): boolean {
return this.selectedSourceIds.has(s.id);
}
toggleSource(s: NotebookSource): void {
if (this.selectedSourceIds.has(s.id)) this.selectedSourceIds.delete(s.id);
else this.selectedSourceIds.add(s.id);
}
get readySourceCount(): number {
return this.sources.filter(s => s.status === 'READY').length;
} }
// --- Sources --- // --- Sources ---
@@ -169,6 +218,81 @@ export class NotebookDetailComponent implements OnInit {
this.service.deleteSource(s.id).subscribe(() => this.reloadSources()); this.service.deleteSource(s.id).subscribe(() => this.reloadSources());
} }
// --- Vider la conversation / archives -------------------------------------
// « Vider » ARCHIVE le fil (rien n'est supprimé) ; les archives restent
// consultables en lecture seule via le sélecteur.
/** Conversations archivées (chargées à l'ouverture du panneau). */
archives: NotebookArchive[] = [];
/** Archive affichée en lecture seule (null = chat actif). */
viewingArchive: NotebookArchive | null = null;
/** Panneau « archives » ouvert ? */
archivesOpen = false;
clearChat(): void {
if (this.sending || this.messages.length === 0) return;
this.confirmDialog.confirm({
title: 'Vider la conversation',
message: 'Repartir d\'une conversation vierge ?',
details: ['Le fil actuel est archivé (pas supprimé) : il restera consultable via « Archives ».'],
confirmLabel: 'Vider',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.service.clearChat(this.notebookId).subscribe({
next: () => {
this.messages = [];
this.archives = []; // re-chargées à la prochaine ouverture du panneau
this.archivesOpen = false;
},
error: () => { /* le fil reste affiché : rien n'a été modifié côté serveur */ }
});
});
}
toggleArchives(): void {
this.archivesOpen = !this.archivesOpen;
if (this.archivesOpen && this.archives.length === 0) {
this.service.getArchives(this.notebookId).subscribe({
next: (a) => this.archives = a,
error: () => { this.archives = []; }
});
}
}
/**
* Archives cochées « en référence » : leur contenu est injecté dans le
* contexte de chaque tour de chat (normal et approfondi). Clés = archivedAt.
*/
referencedArchiveIds = new Set<string>();
isReferenced(a: NotebookArchive): boolean {
return this.referencedArchiveIds.has(a.archivedAt);
}
toggleReference(a: NotebookArchive): void {
if (this.referencedArchiveIds.has(a.archivedAt)) this.referencedArchiveIds.delete(a.archivedAt);
else this.referencedArchiveIds.add(a.archivedAt);
}
openArchive(a: NotebookArchive): void {
this.viewingArchive = a;
this.archivesOpen = false;
}
closeArchive(): void {
this.viewingArchive = null;
}
/** Libellé d'une archive : date du clear + nb d'échanges. */
archiveLabel(a: NotebookArchive): string {
const date = new Date(a.archivedAt);
const when = isNaN(date.getTime())
? a.archivedAt
: date.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
return `${when} · ${a.messages.length} message(s)`;
}
// --- Chat --- // --- Chat ---
send(deep = false): void { send(deep = false): void {
@@ -181,7 +305,10 @@ export class NotebookDetailComponent implements OnInit {
this.messages.push(assistant); this.messages.push(assistant);
this.sending = true; this.sending = true;
this.service.streamChat(this.notebookId, text, deep).subscribe({ this.service.streamChat(
this.notebookId, text, deep,
[...this.selectedSourceIds], [...this.referencedArchiveIds]
).subscribe({
next: (ev) => { next: (ev) => {
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; } if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
else if (ev.type === 'sources') assistant.sources = ev.sources; else if (ev.type === 'sources') assistant.sources = ev.sources;

View File

@@ -0,0 +1,45 @@
<div class="sbl-page">
<div class="sbl-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-create" (click)="create()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouveau PNJ
</button>
</div>
<header class="sbl-header">
<h1><lucide-icon [img]="Drama" [size]="22"></lucide-icon> PNJ</h1>
<p class="sbl-hint">
Tous les personnages non-joueurs de la campagne, classés par dossier.
Le champ « Dossier » de chaque fiche pilote le classement.
</p>
</header>
<div class="sbl-list">
@if (total === 0) {
<p class="empty">Aucun PNJ pour l'instant.</p>
}
@for (g of groups; track g.folder) {
@if (g.folder) {
<h2 class="sbl-folder">
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
{{ g.folder }}
<span class="sbl-folder-count">{{ g.npcs.length }}</span>
</h2>
} @else if (groups.length > 1) {
<h2 class="sbl-folder sbl-folder--none">Non classés</h2>
}
@for (n of g.npcs; track n.id) {
<button class="sbl-item" (click)="open(n)">
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
<span class="sbl-item-name">{{ n.name }}</span>
<span class="sbl-del" (click)="remove(n, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
}
</div>
</div>

View File

@@ -0,0 +1,53 @@
.sbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.sbl-toolbar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.8rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.btn-create { background: #667eea; border-color: #667eea; color: #fff; }
.btn-create:hover { background: #5568d3; }
}
.sbl-header {
margin-bottom: 1.25rem;
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
.sbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
}
.sbl-list { display: flex; flex-direction: column; gap: 0.4rem; }
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
.sbl-folder {
display: flex; align-items: center; gap: 0.4rem;
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em;
color: #a5b4fc;
.sbl-folder-count {
font-weight: 400; color: var(--color-text-muted, #9aa0aa);
}
&.sbl-folder--none { color: var(--color-text-muted, #9aa0aa); }
}
.sbl-item {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 0.85rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.07); }
.sbl-item-name { flex: 1; font-weight: 500; }
.sbl-item-level {
font-size: 0.74rem; padding: 0.12rem 0.5rem; border-radius: 999px;
background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.35); color: #fca5a5;
}
.sbl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
&:hover { background: rgba(224,90,90,0.15); } }
}

View File

@@ -0,0 +1,109 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Drama, Folder } from 'lucide-angular';
import { NpcService } from '../../../services/npc.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { Npc } from '../../../services/npc.model';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/** Groupe d'affichage : un dossier (« Bard's Gate »…) et ses PNJ. */
interface FolderGroup {
folder: string;
npcs: Npc[];
}
/**
* Vue d'ensemble des PNJ d'une campagne, groupés par dossier — pendant « page »
* de l'arbre dépliable de la sidebar (les deux modes coexistent).
* Route : /campaigns/:campaignId/npcs
*/
@Component({
selector: 'app-npc-list',
imports: [LucideAngularModule],
templateUrl: './npc-list.component.html',
styleUrls: ['./npc-list.component.scss']
})
export class NpcListComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Drama = Drama;
readonly Folder = Folder;
campaignId = '';
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
groups: FolderGroup[] = [];
total = 0;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NpcService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.load();
}
}
load(): void {
this.service.getByCampaign(this.campaignId).subscribe({
next: (list) => this.groups = this.groupByFolder(list),
error: () => this.groups = []
});
}
/** Même logique de groupement que la sidebar : dossiers triés, non-classés à la fin. */
private groupByFolder(npcs: Npc[]): FolderGroup[] {
this.total = npcs.length;
const sorted = [...npcs].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
const byFolder = new Map<string, Npc[]>();
const ungrouped: Npc[] = [];
for (const n of sorted) {
const f = (n.folder ?? '').trim();
if (f) {
if (!byFolder.has(f)) byFolder.set(f, []);
byFolder.get(f)!.push(n);
} else {
ungrouped.push(n);
}
}
const groups: FolderGroup[] = [...byFolder.keys()]
.sort((a, b) => a.localeCompare(b, 'fr'))
.map(folder => ({ folder, npcs: byFolder.get(folder)! }));
if (ungrouped.length) groups.push({ folder: '', npcs: ungrouped });
return groups;
}
create(): void {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', 'create']);
}
open(n: Npc): void {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', n.id]);
}
remove(n: Npc, ev: Event): void {
ev.stopPropagation();
this.confirmDialog.confirm({
title: 'Supprimer la fiche',
message: `Supprimer la fiche de « ${n.name} » ?`,
details: ['Cette action est irréversible.'],
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.service.delete(n.id!).subscribe(() => this.load());
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PlaythroughService } from '../../../services/playthrough.service'; import { PlaythroughService } from '../../../services/playthrough.service';
import { SessionService } from '../../../services/session.service'; import { SessionService } from '../../../services/session.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
@@ -56,6 +57,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private playthroughService: PlaythroughService, private playthroughService: PlaythroughService,
private sessionService: SessionService, private sessionService: SessionService,
private layoutService: LayoutService, private layoutService: LayoutService,
@@ -79,7 +81,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService), treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService),
playthrough: this.playthroughService.getById(this.playthroughId), playthrough: this.playthroughService.getById(this.playthroughId),
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))), sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))), characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),

View File

@@ -7,6 +7,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PlaythroughService } from '../../../services/playthrough.service'; import { PlaythroughService } from '../../../services/playthrough.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -38,6 +39,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private playthroughService: PlaythroughService, private playthroughService: PlaythroughService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService private pageTitleService: PageTitleService
@@ -59,7 +61,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService), treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService),
playthrough: this.playthroughService.getById(this.playthroughId) playthrough: this.playthroughService.getById(this.playthroughId)
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => { }).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
this.playthrough = playthrough; this.playthrough = playthrough;

View File

@@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -42,6 +43,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private layoutService: LayoutService private layoutService: LayoutService
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
@@ -61,7 +63,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId); const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
this.chapterName = currentChapter?.name ?? ''; this.chapterName = currentChapter?.name ?? '';

View File

@@ -202,7 +202,19 @@
<input id="scene-edit-combat-difficulty" type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" /> <input id="scene-edit-combat-difficulty" type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
</div> </div>
<div class="field"> <div class="field">
<label for="scene-edit-enemies">Ennemis et créatures</label> <label>Ennemis du bestiaire</label>
<app-enemy-link-picker
[value]="enemyIds"
[availableEnemies]="availableEnemies"
[campaignId]="campaignId"
(valueChange)="enemyIds = $event">
</app-enemy-link-picker>
<small class="field-hint">
Référencez des fiches de votre bestiaire — cliquez sur un chip pour ouvrir la fiche.
</small>
</div>
<div class="field">
<label for="scene-edit-enemies">Ennemis et créatures (texte libre)</label>
<textarea <textarea
id="scene-edit-enemies" id="scene-edit-enemies"
formControlName="enemies" formControlName="enemies"
@@ -246,6 +258,8 @@
</small> </small>
<app-rooms-editor <app-rooms-editor
[rooms]="rooms" [rooms]="rooms"
[availableEnemies]="availableEnemies"
[campaignId]="campaignId"
(roomsChange)="onRoomsChange($event)"> (roomsChange)="onRoomsChange($event)">
</app-rooms-editor> </app-rooms-editor>
</app-expandable-section> </app-expandable-section>

View File

@@ -9,14 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
import { Scene, SceneBranch, Room } from '../../../services/campaign.model'; import { Scene, SceneBranch, Room } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model'; import { Page } from '../../../services/page.model';
import { Enemy } from '../../../services/enemy.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component'; import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component'; import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { EnemyLinkPickerComponent } from '../../../shared/enemy-link-picker/enemy-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -30,7 +33,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-scene-edit', selector: 'app-scene-edit',
imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent], imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent],
templateUrl: './scene-edit.component.html', templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss'] styleUrls: ['./scene-edit.component.scss']
}) })
@@ -60,6 +63,9 @@ export class SceneEditComponent implements OnInit, OnDestroy {
availablePages: Page[] = []; availablePages: Page[] = [];
loreId: string | null = null; loreId: string | null = null;
relatedPageIds: string[] = []; relatedPageIds: string[] = [];
/** Bestiaire de la campagne + fiches liées à la rencontre. */
availableEnemies: Enemy[] = [];
enemyIds: string[] = [];
illustrationImageIds: string[] = []; illustrationImageIds: string[] = [];
mapImageIds: string[] = []; mapImageIds: string[] = [];
@@ -81,6 +87,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -133,7 +140,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId), scene: this.campaignService.getSceneById(this.sceneId),
chapterScenes: this.campaignService.getScenes(this.chapterId), chapterScenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;
@@ -146,6 +153,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.loreId = loreId; this.loreId = loreId;
this.availablePages = pages; this.availablePages = pages;
this.relatedPageIds = [...(scene.relatedPageIds ?? [])]; this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
this.availableEnemies = treeData.enemies ?? [];
this.enemyIds = [...(scene.enemyIds ?? [])];
this.selectedIcon = scene.icon ?? null; this.selectedIcon = scene.icon ?? null;
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])]; this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
this.mapImageIds = [...(scene.mapImageIds ?? [])]; this.mapImageIds = [...(scene.mapImageIds ?? [])];
@@ -184,6 +193,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
choicesConsequences: this.form.value.choicesConsequences, choicesConsequences: this.form.value.choicesConsequences,
combatDifficulty: this.form.value.combatDifficulty, combatDifficulty: this.form.value.combatDifficulty,
enemies: this.form.value.enemies, enemies: this.form.value.enemies,
enemyIds: this.enemyIds,
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds, illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds, mapImageIds: this.mapImageIds,

View File

@@ -81,17 +81,29 @@
</section> </section>
} }
<!-- Combat ou rencontre --> <!-- Combat ou rencontre -->
@if (scene.combatDifficulty?.trim() || scene.enemies?.trim()) { @if (scene.combatDifficulty?.trim() || scene.enemies?.trim() || linkedEnemies.length > 0) {
@if (scene.combatDifficulty?.trim()) { @if (scene.combatDifficulty?.trim()) {
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2> <h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2>
<p class="view-section-body">{{ scene.combatDifficulty }}</p> <p class="view-section-body">{{ scene.combatDifficulty }}</p>
</section> </section>
} }
@if (scene.enemies?.trim()) { @if (scene.enemies?.trim() || linkedEnemies.length > 0) {
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2> <h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2>
@if (linkedEnemies.length > 0) {
<div class="view-chips">
@for (e of linkedEnemies; track e.id) {
<a class="view-chip"
[routerLink]="['/campaigns', campaignId, 'enemies', e.id]">
{{ enemyLabel(e) }}
</a>
}
</div>
}
@if (scene.enemies?.trim()) {
<p class="view-section-body">{{ scene.enemies }}</p> <p class="view-section-body">{{ scene.enemies }}</p>
}
</section> </section>
} }
} }
@@ -139,10 +151,22 @@
<p class="room-readonly-desc">{{ r.description }}</p> <p class="room-readonly-desc">{{ r.description }}</p>
} }
<div class="room-readonly-grid"> <div class="room-readonly-grid">
@if (r.enemies?.trim()) { @if (r.enemies?.trim() || roomLinkedEnemies(r).length > 0) {
<div> <div>
<strong>⚔️ Ennemis</strong> <strong>⚔️ Ennemis</strong>
@if (roomLinkedEnemies(r).length > 0) {
<div class="view-chips">
@for (e of roomLinkedEnemies(r); track e.id) {
<a class="view-chip"
[routerLink]="['/campaigns', campaignId, 'enemies', e.id]">
{{ enemyLabel(e) }}
</a>
}
</div>
}
@if (r.enemies?.trim()) {
<p>{{ r.enemies }}</p> <p>{{ r.enemies }}</p>
}
</div> </div>
} }
@if (r.loot?.trim()) { @if (r.loot?.trim()) {

View File

@@ -9,11 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
import { Scene } from '../../../services/campaign.model'; import { Scene, Room } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model'; import { Page } from '../../../services/page.model';
import { Enemy } from '../../../services/enemy.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
@@ -41,6 +43,8 @@ export class SceneViewComponent implements OnInit, OnDestroy {
loreId: string | null = null; loreId: string | null = null;
availablePages: Page[] = []; availablePages: Page[] = [];
/** Bestiaire de la campagne — résout les enemyIds de la scène en fiches. */
availableEnemies: Enemy[] = [];
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -49,6 +53,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -79,7 +84,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId), scene: this.campaignService.getSceneById(this.sceneId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;
@@ -90,6 +95,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
this.scene = scene; this.scene = scene;
this.loreId = loreId; this.loreId = loreId;
this.availablePages = pages; this.availablePages = pages;
this.availableEnemies = treeData.enemies ?? [];
this.pageTitleService.set(scene.name); this.pageTitleService.set(scene.name);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
@@ -100,6 +106,26 @@ export class SceneViewComponent implements OnInit, OnDestroy {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)'; return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
} }
/** Fiches du bestiaire liées à la rencontre (IDs orphelins ignorés). */
get linkedEnemies(): Enemy[] {
return (this.scene?.enemyIds ?? [])
.map(id => this.availableEnemies.find(e => e.id === id))
.filter((e): e is Enemy => !!e);
}
/** Libellé d'un chip ennemi : nom + niveau s'il est renseigné. */
enemyLabel(enemy: Enemy): string {
const level = enemy.level?.trim();
return level ? `${enemy.name} (${level})` : enemy.name;
}
/** Fiches du bestiaire liées à une pièce (IDs orphelins ignorés). */
roomLinkedEnemies(room: Room): Enemy[] {
return (room.enemyIds ?? [])
.map(id => this.availableEnemies.find(e => e.id === id))
.filter((e): e is Enemy => !!e);
}
/** Résout le nom d'une pièce cible (pour afficher les sorties inter-pièces). */ /** Résout le nom d'une pièce cible (pour afficher les sorties inter-pièces). */
roomNameById(scene: Scene | null, roomId: string): string { roomNameById(scene: Scene | null, roomId: string): string {
return scene?.rooms?.find(r => r.id === roomId)?.name ?? '(pièce supprimée)'; return scene?.rooms?.find(r => r.id === roomId)?.name ?? '(pièce supprimée)';

View File

@@ -159,6 +159,14 @@
[suggestions]="npcFieldSuggestions" [suggestions]="npcFieldSuggestions"
(fieldsChange)="npcTemplate = $event"> (fieldsChange)="npcTemplate = $event">
</app-template-fields-editor> </app-template-fields-editor>
<app-template-fields-editor
label="Champs de la fiche Ennemi"
hint="Affiches lors de la creation/edition d'un ennemi (bestiaire). Nom, niveau, dossier, portrait et bandeau sont automatiques."
[fields]="enemyTemplate"
[suggestions]="enemyFieldSuggestions"
(fieldsChange)="enemyTemplate = $event">
</app-template-fields-editor>
</div> </div>
<div class="actions"> <div class="actions">

View File

@@ -39,6 +39,9 @@ const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'N
/** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */ /** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */
const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ']; const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ'];
/** Suggestions de champs pour la fiche ENNEMI — stats de combat. */
const ENEMY_FIELD_SUGGESTIONS = ['Stats', 'Attaques', 'Capacités', 'Faiblesses', 'Butin', 'Tactique'];
@Component({ @Component({
selector: 'app-game-system-edit', selector: 'app-game-system-edit',
imports: [FormsModule, LucideAngularModule, TemplateFieldsEditorComponent], imports: [FormsModule, LucideAngularModule, TemplateFieldsEditorComponent],
@@ -82,10 +85,12 @@ export class GameSystemEditComponent implements OnInit {
sections: RuleSection[] = []; sections: RuleSection[] = [];
characterTemplate: TemplateField[] = []; characterTemplate: TemplateField[] = [];
npcTemplate: TemplateField[] = []; npcTemplate: TemplateField[] = [];
enemyTemplate: TemplateField[] = [];
readonly suggestedSections = SUGGESTED_SECTIONS; readonly suggestedSections = SUGGESTED_SECTIONS;
readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS; readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS; readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
readonly enemyFieldSuggestions = ENEMY_FIELD_SUGGESTIONS;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -104,6 +109,7 @@ export class GameSystemEditComponent implements OnInit {
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? ''); this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : []; this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : []; this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
this.enemyTemplate = gs.enemyTemplate ? [...gs.enemyTemplate] : [];
}, },
error: () => this.back() error: () => this.back()
}); });
@@ -243,6 +249,7 @@ export class GameSystemEditComponent implements OnInit {
rulesMarkdown: this.serializeMarkdown(), rulesMarkdown: this.serializeMarkdown(),
characterTemplate: this.characterTemplate, characterTemplate: this.characterTemplate,
npcTemplate: this.npcTemplate, npcTemplate: this.npcTemplate,
enemyTemplate: this.enemyTemplate,
isPublic: false isPublic: false
}; };
const req = this.id const req = this.id
@@ -260,7 +267,9 @@ export class GameSystemEditComponent implements OnInit {
/** Validation cote front : nom vide ou doublons (case-insensitive). */ /** Validation cote front : nom vide ou doublons (case-insensitive). */
private hasInvalidTemplateFields(): boolean { private hasInvalidTemplateFields(): boolean {
return this.hasInvalidList(this.characterTemplate) || this.hasInvalidList(this.npcTemplate); return this.hasInvalidList(this.characterTemplate)
|| this.hasInvalidList(this.npcTemplate)
|| this.hasInvalidList(this.enemyTemplate);
} }
private hasInvalidList(fields: TemplateField[]): boolean { private hasInvalidList(fields: TemplateField[]): boolean {

View File

@@ -4,6 +4,7 @@ import { CampaignService } from './campaign.service';
import { CharacterService } from './character.service'; import { CharacterService } from './character.service';
import { NpcService } from './npc.service'; import { NpcService } from './npc.service';
import { RandomTableService } from './random-table.service'; import { RandomTableService } from './random-table.service';
import { EnemyService } from './enemy.service';
import { LayoutService } from './layout.service'; import { LayoutService } from './layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper';
@@ -28,6 +29,7 @@ export class CampaignSidebarService {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private layoutService: LayoutService private layoutService: LayoutService
) {} ) {}
@@ -45,7 +47,8 @@ export class CampaignSidebarService {
campaignId, campaignId,
this.characterService, this.characterService,
this.npcService, this.npcService,
this.randomTableService this.randomTableService,
this.enemyService
) )
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId)); this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId));

View File

@@ -202,6 +202,8 @@ export interface Room {
name: string; name: string;
description?: string; description?: string;
enemies?: string; enemies?: string;
/** IDs des fiches du bestiaire présentes dans la pièce (weak refs). */
enemyIds?: string[];
loot?: string; loot?: string;
traps?: string; traps?: string;
gmNotes?: string; gmNotes?: string;
@@ -231,6 +233,9 @@ export interface Scene {
combatDifficulty?: string; combatDifficulty?: string;
enemies?: string; enemies?: string;
/** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */
enemyIds?: string[];
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[]; mapImageIds?: string[];
@@ -258,6 +263,9 @@ export interface SceneCreate {
combatDifficulty?: string; combatDifficulty?: string;
enemies?: string; enemies?: string;
/** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */
enemyIds?: string[];
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[]; mapImageIds?: string[];

View File

@@ -1,8 +1,16 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Character, CharacterCreate } from './character.model'; import { Character, CharacterCreate } from './character.model';
/** Résultat de recherche d'un PJ, enrichi du campaignId pour la navigation. */
export interface CharacterSearchResult {
id: string;
name: string;
playthroughId: string;
campaignId: string;
}
/** /**
* Service HTTP pour les fiches de personnages (PJ) d'une campagne. * Service HTTP pour les fiches de personnages (PJ) d'une campagne.
*/ */
@@ -31,4 +39,10 @@ export class CharacterService {
delete(id: string): Observable<void> { delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`); return this.http.delete<void>(`${this.apiUrl}/${id}`);
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
search(q: string): Observable<CharacterSearchResult[]> {
const params = new HttpParams().set('q', q);
return this.http.get<CharacterSearchResult[]>(`${this.apiUrl}/search`, { params });
}
} }

View File

@@ -0,0 +1,32 @@
/**
* Fiche d'ennemi (monstre/créature) d'une campagne — le bestiaire du MJ.
* Même structure de templating que Npc : champs pilotés par le template
* ENNEMI du GameSystem, + champs universels niveau/dossier.
*/
export interface Enemy {
id?: string;
name: string;
/** Niveau / FP / dangerosité — texte libre (« 5 », « FP 8 », « Boss »). */
level?: string | null;
/** Dossier de classement (« Démons », « Humanoïdes »…). Vide = non classé. */
folder?: string | null;
portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
order?: number;
}
export interface EnemyCreate {
name: string;
level?: string | null;
folder?: string | null;
portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
}

Some files were not shown because too many files have changed in this diff Show More