Compare commits
2 Commits
v0.12.6-be
...
v0.13.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
| c77c0bc994 | |||
| 6035df262d |
@@ -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":"..."}}]}}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.6-beta",
|
version="0.13.0-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.12.6-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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Enemy;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
|
||||||
|
* Miroir de {@link NpcService} : fiche pilotée par le template ENNEMI du GameSystem.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class EnemyService {
|
||||||
|
|
||||||
|
private final EnemyRepository enemyRepository;
|
||||||
|
|
||||||
|
public EnemyService(EnemyRepository enemyRepository) {
|
||||||
|
this.enemyRepository = enemyRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EnemyData(
|
||||||
|
String name,
|
||||||
|
String level,
|
||||||
|
String folder,
|
||||||
|
String portraitImageId,
|
||||||
|
String headerImageId,
|
||||||
|
Map<String, String> values,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
|
String campaignId,
|
||||||
|
Integer order
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public Enemy createEnemy(EnemyData data) {
|
||||||
|
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
|
||||||
|
Enemy enemy = Enemy.builder()
|
||||||
|
.name(data.name())
|
||||||
|
.level(normalize(data.level()))
|
||||||
|
.folder(normalize(data.folder()))
|
||||||
|
.portraitImageId(data.portraitImageId())
|
||||||
|
.headerImageId(data.headerImageId())
|
||||||
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||||
|
.campaignId(data.campaignId())
|
||||||
|
.order(order)
|
||||||
|
.build();
|
||||||
|
return enemyRepository.save(enemy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Enemy> getEnemyById(String id) {
|
||||||
|
return enemyRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Enemy> getEnemiesByCampaignId(String campaignId) {
|
||||||
|
return enemyRepository.findByCampaignId(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Enemy updateEnemy(String id, EnemyData data) {
|
||||||
|
Enemy existing = enemyRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
|
||||||
|
existing.setName(data.name());
|
||||||
|
existing.setLevel(normalize(data.level()));
|
||||||
|
existing.setFolder(normalize(data.folder()));
|
||||||
|
existing.setPortraitImageId(data.portraitImageId());
|
||||||
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
|
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||||
|
if (data.order() != null) {
|
||||||
|
existing.setOrder(data.order());
|
||||||
|
}
|
||||||
|
return enemyRepository.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteEnemy(String id) {
|
||||||
|
enemyRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Enemy> searchEnemies(String query) {
|
||||||
|
if (query == null || query.isBlank()) return List.of();
|
||||||
|
return enemyRepository.searchByName(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim ; chaîne vide → null (= non renseigné / non classé). */
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int nextOrderFor(String campaignId) {
|
||||||
|
return enemyRepository.findByCampaignId(campaignId).stream()
|
||||||
|
.mapToInt(Enemy::getOrder)
|
||||||
|
.max()
|
||||||
|
.orElse(-1) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,11 @@ public class ItemCatalogService {
|
|||||||
repository.deleteById(id);
|
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));
|
||||||
|
|||||||
@@ -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 ---
|
||||||
@@ -179,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.loremind.domain.campaigncontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fiche d'ennemi (monstre/créature) d'une campagne — le bestiaire du MJ.
|
||||||
|
* <p>
|
||||||
|
* Même principe de templating que {@link Npc} : champs universels hard-codés
|
||||||
|
* (nom, niveau, dossier, portrait, bandeau) + champs pilotés par le template
|
||||||
|
* ENNEMI du GameSystem ({@code GameSystem.enemyTemplate} : CA, PV, attaques…).
|
||||||
|
* Classement libre par dossier (« Démons », « Humanoïdes »…).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class Enemy {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Niveau / FP / dangerosité — texte libre (« 5 », « FP 8 », « Boss »). Nullable. */
|
||||||
|
private String level;
|
||||||
|
|
||||||
|
/** Dossier de classement (texte libre). Null = non classé. */
|
||||||
|
private String folder;
|
||||||
|
|
||||||
|
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
|
||||||
|
private String portraitImageId;
|
||||||
|
|
||||||
|
/** ID de l'image header/bannière (champ universel hard-codé). Nullable. */
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
/** Valeurs TEXT/NUMBER du template ennemi. Jamais null après construction. */
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/** Valeurs IMAGE du template ennemi (listes d'IDs ordonnées par champ). Jamais null. */
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
|
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||||
|
private String campaignId;
|
||||||
|
|
||||||
|
/** Ordre d'affichage dans la liste. */
|
||||||
|
private int order;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Map<String, String> getValues() {
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, List<String>> getImageValues() {
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
return imageValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
|
return keyValueValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,13 @@ public class Room {
|
|||||||
/** Énemis, créatures, boss éventuels (markdown libre). */
|
/** É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;
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Enemy;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la persistance des fiches d'ennemis (bestiaire de campagne).
|
||||||
|
*/
|
||||||
|
public interface EnemyRepository {
|
||||||
|
|
||||||
|
Enemy save(Enemy enemy);
|
||||||
|
|
||||||
|
Optional<Enemy> findById(String id);
|
||||||
|
|
||||||
|
List<Enemy> findByCampaignId(String campaignId);
|
||||||
|
|
||||||
|
void deleteById(String id);
|
||||||
|
|
||||||
|
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||||
|
List<Enemy> searchByName(String query);
|
||||||
|
}
|
||||||
@@ -19,4 +19,7 @@ public interface ItemCatalogRepository {
|
|||||||
void deleteById(String id);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) -> {
|
||||||
.map(this::chapterSummaryToMap)
|
// Vocabulaire UI : les chapitres d'un arc HUB sont des « quêtes ».
|
||||||
.collect(Collectors.toList())));
|
if (arc.hub()) {
|
||||||
|
map.put("arc_type", "HUB");
|
||||||
|
}
|
||||||
|
map.put("chapters", arc.chapters().stream()
|
||||||
|
.map(this::chapterSummaryToMap)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entité JPA des fiches d'ennemis (bestiaire). Mêmes règles que NpcJpaEntity.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "enemies", indexes = {
|
||||||
|
@Index(name = "idx_enemies_campaign_id", columnList = "campaign_id")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class EnemyJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Niveau / FP — texte libre. Nullable. */
|
||||||
|
@Column(name = "level")
|
||||||
|
private String level;
|
||||||
|
|
||||||
|
/** Dossier de classement (« Démons », « Humanoïdes »…). Nullable = non classé. */
|
||||||
|
@Column(name = "folder")
|
||||||
|
private String folder;
|
||||||
|
|
||||||
|
@Column(name = "portrait_image_id")
|
||||||
|
private String portraitImageId;
|
||||||
|
|
||||||
|
@Column(name = "header_image_id")
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
@Convert(converter = StringMapJsonConverter.class)
|
||||||
|
@Column(name = "field_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
@Convert(converter = StringListMapJsonConverter.class)
|
||||||
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
@Convert(converter = StringMapMapJsonConverter.class)
|
||||||
|
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
|
@Column(name = "campaign_id", nullable = false)
|
||||||
|
private Long campaignId;
|
||||||
|
|
||||||
|
@Column(name = "\"order\"", nullable = false)
|
||||||
|
private int order;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,11 @@ public class GameSystemJpaEntity {
|
|||||||
@Column(name = "npc_template", columnDefinition = "TEXT")
|
@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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.jpa;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface EnemyJpaRepository extends JpaRepository<EnemyJpaEntity, Long> {
|
||||||
|
|
||||||
|
List<EnemyJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||||
|
|
||||||
|
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||||
|
List<EnemyJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||||
|
}
|
||||||
@@ -10,4 +10,7 @@ import java.util.List;
|
|||||||
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Enemy;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||||
|
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
|
||||||
|
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresEnemyRepository implements EnemyRepository {
|
||||||
|
|
||||||
|
private final EnemyJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
public PostgresEnemyRepository(EnemyJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Enemy save(Enemy enemy) {
|
||||||
|
return toDomainEntity(jpaRepository.save(toJpaEntity(enemy)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Enemy> findById(String id) {
|
||||||
|
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Enemy> findByCampaignId(String campaignId) {
|
||||||
|
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(String id) {
|
||||||
|
jpaRepository.deleteById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Enemy> searchByName(String query) {
|
||||||
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Enemy toDomainEntity(EnemyJpaEntity e) {
|
||||||
|
return Enemy.builder()
|
||||||
|
.id(e.getId().toString())
|
||||||
|
.name(e.getName())
|
||||||
|
.level(e.getLevel())
|
||||||
|
.folder(e.getFolder())
|
||||||
|
.portraitImageId(e.getPortraitImageId())
|
||||||
|
.headerImageId(e.getHeaderImageId())
|
||||||
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
|
.campaignId(e.getCampaignId().toString())
|
||||||
|
.order(e.getOrder())
|
||||||
|
.createdAt(e.getCreatedAt())
|
||||||
|
.updatedAt(e.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private EnemyJpaEntity toJpaEntity(Enemy n) {
|
||||||
|
return EnemyJpaEntity.builder()
|
||||||
|
.id(n.getId() != null ? Long.parseLong(n.getId()) : null)
|
||||||
|
.name(n.getName())
|
||||||
|
.level(n.getLevel())
|
||||||
|
.folder(n.getFolder())
|
||||||
|
.portraitImageId(n.getPortraitImageId())
|
||||||
|
.headerImageId(n.getHeaderImageId())
|
||||||
|
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
||||||
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
|
.order(n.getOrder())
|
||||||
|
.createdAt(n.getCreatedAt())
|
||||||
|
.updatedAt(n.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
.npcTemplate(e.getNpcTemplate() != null
|
.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())
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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<>())
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.campaigncontext.EnemyService;
|
||||||
|
import com.loremind.domain.campaigncontext.Enemy;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller des fiches d'ennemis (bestiaire de campagne).
|
||||||
|
* Réponses = domaine {@link Enemy} sérialisé tel quel (Lombok @Data) ;
|
||||||
|
* requêtes = record dédié (le domaine n'a pas de constructeur no-args).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/enemies")
|
||||||
|
public class EnemyController {
|
||||||
|
|
||||||
|
private final EnemyService enemyService;
|
||||||
|
|
||||||
|
public EnemyController(EnemyService enemyService) {
|
||||||
|
this.enemyService = enemyService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Enemy> create(@RequestBody EnemyRequest req) {
|
||||||
|
return ResponseEntity.ok(enemyService.createEnemy(toData(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Enemy> getById(@PathVariable String id) {
|
||||||
|
return enemyService.getEnemyById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/campaign/{campaignId}")
|
||||||
|
public ResponseEntity<List<Enemy>> getByCampaign(@PathVariable String campaignId) {
|
||||||
|
return ResponseEntity.ok(enemyService.getEnemiesByCampaignId(campaignId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<List<Enemy>> search(@RequestParam("q") String query) {
|
||||||
|
return ResponseEntity.ok(enemyService.searchEnemies(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<Enemy> update(@PathVariable String id, @RequestBody EnemyRequest req) {
|
||||||
|
return ResponseEntity.ok(enemyService.updateEnemy(id, toData(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable String id) {
|
||||||
|
enemyService.deleteEnemy(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private EnemyService.EnemyData toData(EnemyRequest req) {
|
||||||
|
return new EnemyService.EnemyData(
|
||||||
|
req.name(), req.level(), req.folder(),
|
||||||
|
req.portraitImageId(), req.headerImageId(),
|
||||||
|
req.values(), req.imageValues(), req.keyValueValues(),
|
||||||
|
req.campaignId(), req.order());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EnemyRequest(
|
||||||
|
String name,
|
||||||
|
String level,
|
||||||
|
String folder,
|
||||||
|
String portraitImageId,
|
||||||
|
String headerImageId,
|
||||||
|
Map<String, String> values,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
|
String campaignId,
|
||||||
|
Integer order) {}
|
||||||
|
}
|
||||||
@@ -263,6 +263,7 @@ public class GameSystemController {
|
|||||||
dto.getRulesMarkdown(),
|
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()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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<>();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.6-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.6-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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.6-beta",
|
"version": "0.13.0-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? '';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
111
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html
Normal file
111
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html
Normal 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>
|
||||||
157
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss
Normal file
157
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts
Normal file
167
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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); } }
|
||||||
|
}
|
||||||
107
web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts
Normal file
107
web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!]),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
|
|||||||
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 { EnemyService } from '../../../services/enemy.service';
|
||||||
import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model';
|
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';
|
||||||
@@ -66,6 +67,7 @@ export class NotebookDetailComponent implements OnInit {
|
|||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private confirmDialog: ConfirmDialogService
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -80,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 */ }
|
||||||
|
|||||||
45
web/src/app/campaigns/npc/npc-list/npc-list.component.html
Normal file
45
web/src/app/campaigns/npc/npc-list/npc-list.component.html
Normal 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>
|
||||||
53
web/src/app/campaigns/npc/npc-list/npc-list.component.scss
Normal file
53
web/src/app/campaigns/npc/npc-list/npc-list.component.scss
Normal 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); } }
|
||||||
|
}
|
||||||
109
web/src/app/campaigns/npc/npc-list/npc-list.component.ts
Normal file
109
web/src/app/campaigns/npc/npc-list/npc-list.component.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[]))),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ?? '';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
<p class="view-section-body">{{ scene.enemies }}</p>
|
@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>
|
||||||
|
}
|
||||||
</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>
|
||||||
<p>{{ r.enemies }}</p>
|
@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>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (r.loot?.trim()) {
|
@if (r.loot?.trim()) {
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
web/src/app/services/enemy.model.ts
Normal file
32
web/src/app/services/enemy.model.ts
Normal 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;
|
||||||
|
}
|
||||||
39
web/src/app/services/enemy.service.ts
Normal file
39
web/src/app/services/enemy.service.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Enemy, EnemyCreate } from './enemy.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service HTTP des fiches d'ennemis (bestiaire de campagne).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class EnemyService {
|
||||||
|
private apiUrl = '/api/enemies';
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
getByCampaign(campaignId: string): Observable<Enemy[]> {
|
||||||
|
return this.http.get<Enemy[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): Observable<Enemy> {
|
||||||
|
return this.http.get<Enemy>(`${this.apiUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(payload: EnemyCreate): Observable<Enemy> {
|
||||||
|
return this.http.post<Enemy>(this.apiUrl, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, payload: Enemy): Observable<Enemy> {
|
||||||
|
return this.http.put<Enemy>(`${this.apiUrl}/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||||
|
search(q: string): Observable<Enemy[]> {
|
||||||
|
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export interface GameSystem {
|
|||||||
rulesMarkdown?: string | null;
|
rulesMarkdown?: string | null;
|
||||||
characterTemplate?: TemplateField[];
|
characterTemplate?: TemplateField[];
|
||||||
npcTemplate?: TemplateField[];
|
npcTemplate?: TemplateField[];
|
||||||
|
enemyTemplate?: TemplateField[];
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ export interface GameSystemCreate {
|
|||||||
rulesMarkdown?: string | null;
|
rulesMarkdown?: string | null;
|
||||||
characterTemplate?: TemplateField[];
|
characterTemplate?: TemplateField[];
|
||||||
npcTemplate?: TemplateField[];
|
npcTemplate?: TemplateField[];
|
||||||
|
enemyTemplate?: TemplateField[];
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export class ItemCatalogService {
|
|||||||
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<ItemCatalog[]> {
|
||||||
|
return this.http.get<ItemCatalog[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||||
|
}
|
||||||
|
|
||||||
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) à préremplir. */
|
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) à préremplir. */
|
||||||
generate(campaignId: string, description: string): Observable<ItemCatalog> {
|
generate(campaignId: string, description: string): Observable<ItemCatalog> {
|
||||||
return this.http.post<ItemCatalog>(`${this.apiUrl}/generate`, { campaignId, description });
|
return this.http.post<ItemCatalog>(`${this.apiUrl}/generate`, { campaignId, description });
|
||||||
|
|||||||
@@ -15,10 +15,37 @@ export interface NotebookAction {
|
|||||||
type: 'npc' | 'scene' | 'chapter' | 'arc' | 'table';
|
type: 'npc' | 'scene' | 'chapter' | 'arc' | 'table';
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Legacy (anciens messages) : déroulé d'une scène → repli sur playerNarration. */
|
||||||
content?: string;
|
content?: string;
|
||||||
arcType?: 'LINEAR' | 'HUB';
|
arcType?: 'LINEAR' | 'HUB';
|
||||||
diceFormula?: string;
|
diceFormula?: string;
|
||||||
entries?: NotebookActionEntry[];
|
entries?: NotebookActionEntry[];
|
||||||
|
|
||||||
|
// --- PNJ : valeurs des champs TEXT de la fiche (clés = template du système). ---
|
||||||
|
values?: Record<string, string>;
|
||||||
|
|
||||||
|
// --- Scène : champs narratifs enrichis (miroir de SceneCreate). ---
|
||||||
|
location?: string;
|
||||||
|
timing?: string;
|
||||||
|
atmosphere?: string;
|
||||||
|
playerNarration?: string;
|
||||||
|
gmSecretNotes?: string;
|
||||||
|
choicesConsequences?: string;
|
||||||
|
combatDifficulty?: string;
|
||||||
|
enemies?: string;
|
||||||
|
|
||||||
|
// --- Chapitre. ---
|
||||||
|
playerObjectives?: string;
|
||||||
|
narrativeStakes?: string;
|
||||||
|
|
||||||
|
// --- Chapitre + Arc. ---
|
||||||
|
gmNotes?: string;
|
||||||
|
|
||||||
|
// --- Arc. ---
|
||||||
|
themes?: string;
|
||||||
|
stakes?: string;
|
||||||
|
rewards?: string;
|
||||||
|
resolution?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g;
|
const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g;
|
||||||
|
|||||||
@@ -36,4 +36,9 @@ export class NpcService {
|
|||||||
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<Npc[]> {
|
||||||
|
return this.http.get<Npc[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export class RandomTableService {
|
|||||||
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<RandomTable[]> {
|
||||||
|
return this.http.get<RandomTable[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||||
|
}
|
||||||
|
|
||||||
/** Génère une PROPOSITION de table via l'IA (non persistée) à préremplir dans le formulaire. */
|
/** Génère une PROPOSITION de table via l'IA (non persistée) à préremplir dans le formulaire. */
|
||||||
generate(campaignId: string, description: string, diceFormula: string): Observable<RandomTable> {
|
generate(campaignId: string, description: string, diceFormula: string): Observable<RandomTable> {
|
||||||
return this.http.post<RandomTable>(`${this.apiUrl}/generate`, { campaignId, description, diceFormula });
|
return this.http.post<RandomTable>(`${this.apiUrl}/generate`, { campaignId, description, diceFormula });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { catchError, of } from 'rxjs';
|
|||||||
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 { EnemyService } from '../../services/enemy.service';
|
||||||
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 { Arc, Chapter, Scene } from '../../services/campaign.model';
|
import { Arc, Chapter, Scene } from '../../services/campaign.model';
|
||||||
@@ -70,7 +71,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
|||||||
constructor(
|
constructor(
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService
|
private npcService: NpcService,
|
||||||
|
private enemyService: EnemyService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
@@ -113,8 +115,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
|||||||
private ensureTreeLoaded(): void {
|
private ensureTreeLoaded(): void {
|
||||||
if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
|
if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
|
||||||
this.loadingTree = true;
|
this.loadingTree = true;
|
||||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService).pipe(
|
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService).pipe(
|
||||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
|
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||||
).subscribe(data => {
|
).subscribe(data => {
|
||||||
this.treeData = data;
|
this.treeData = data;
|
||||||
this.loadingTree = false;
|
this.loadingTree = false;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<div class="picker">
|
||||||
|
|
||||||
|
<!-- Chips des ennemis déjà liés -->
|
||||||
|
@if (linkedEnemies.length > 0) {
|
||||||
|
<div class="linked-chips">
|
||||||
|
@for (e of linkedEnemies; track e.id) {
|
||||||
|
<span class="chip">
|
||||||
|
<a
|
||||||
|
class="chip-label"
|
||||||
|
[href]="enemyUrl(e.id!)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
title="Ouvrir la fiche dans un nouvel onglet">
|
||||||
|
<lucide-icon [img]="Skull" [size]="12"></lucide-icon>
|
||||||
|
{{ label(e) }}
|
||||||
|
</a>
|
||||||
|
<button type="button" class="chip-remove" (click)="remove(e.id!)" title="Retirer le lien">
|
||||||
|
<lucide-icon [img]="X" [size]="12"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Input + dropdown de suggestions -->
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Rechercher un ennemi du bestiaire..."
|
||||||
|
[(ngModel)]="query"
|
||||||
|
(focus)="dropdownOpen = true"
|
||||||
|
(blur)="onBlur()" />
|
||||||
|
|
||||||
|
@if (dropdownOpen && suggestions.length > 0) {
|
||||||
|
<ul class="suggestions">
|
||||||
|
@for (e of suggestions; track e.id) {
|
||||||
|
<li (mousedown)="add(e)">
|
||||||
|
{{ label(e) }}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (dropdownOpen && query && suggestions.length === 0) {
|
||||||
|
<p class="empty-hint">
|
||||||
|
Aucun ennemi ne correspond
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user