From 6035df262dfad7bbdb3a6aebe65c3e6c35a65899 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Fri, 12 Jun 2026 23:38:43 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20la=20possibilit=C3=A9=20de=20faire?= =?UTF-8?q?=20des=20stats=20blocs=20pour=20tout=20ce=20qui=20est=20ennemis?= =?UTF-8?q?=20/=20cr=C3=A9atures=20adverses.=20Dor=C3=A9navant,=20l'IA=20e?= =?UTF-8?q?st=20capable=20de=20prendre=20en=20compte=20le=20format=20des?= =?UTF-8?q?=20qu=C3=AAtes,=20chapitres,=20Arc....=20pour=20proposer=20des?= =?UTF-8?q?=20blocs=20plus=20complets.=20Les=20ennemis=20sont=20=C3=A9gale?= =?UTF-8?q?ment=20r=C3=A9f=C3=A9ren=C3=A7ables=20directement=20dans=20la?= =?UTF-8?q?=20campagne.=20Les=20r=C3=A9f=C3=A9rences=20vers=20les=20ennemi?= =?UTF-8?q?s=20dans=20la=20partie=20"donjon"=20est=20en=20cours=20d'ajout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/application/notebook_chat.py | 56 ++++-- brain/app/main.py | 2 +- core/pom.xml | 2 +- .../campaigncontext/CampaignBriefBuilder.java | 6 +- .../campaigncontext/CharacterService.java | 5 + .../campaigncontext/EnemyService.java | 102 +++++++++++ .../campaigncontext/ItemCatalogService.java | 5 + .../campaigncontext/NotebookService.java | 26 ++- .../campaigncontext/NpcService.java | 5 + .../campaigncontext/RandomTableService.java | 5 + .../gamesystemcontext/GameSystemService.java | 3 + .../CampaignStructuralContextBuilder.java | 60 +++++-- .../NarrativeEntityContextBuilder.java | 37 +++- .../domain/campaigncontext/Enemy.java | 70 ++++++++ .../loremind/domain/campaigncontext/Room.java | 7 + .../domain/campaigncontext/Scene.java | 10 +- .../ports/CharacterRepository.java | 3 + .../ports/EnemyRepository.java | 23 +++ .../ports/ItemCatalogRepository.java | 3 + .../campaigncontext/ports/NpcRepository.java | 3 + .../ports/RandomTableRepository.java | 3 + .../domain/gamesystemcontext/GameSystem.java | 12 ++ .../CampaignStructuralContext.java | 4 + .../ai/BrainChatPayloadBuilder.java | 12 +- .../persistence/entity/EnemyJpaEntity.java | 88 +++++++++ .../entity/GameSystemJpaEntity.java | 6 + .../persistence/entity/SceneJpaEntity.java | 6 + .../jpa/CharacterJpaRepository.java | 3 + .../persistence/jpa/EnemyJpaRepository.java | 16 ++ .../jpa/ItemCatalogJpaRepository.java | 3 + .../persistence/jpa/NpcJpaRepository.java | 3 + .../jpa/RandomTableJpaRepository.java | 3 + .../postgres/PostgresCharacterRepository.java | 7 + .../postgres/PostgresEnemyRepository.java | 87 +++++++++ .../PostgresGameSystemRepository.java | 6 + .../PostgresItemCatalogRepository.java | 8 + .../postgres/PostgresNpcRepository.java | 7 + .../PostgresRandomTableRepository.java | 8 + .../postgres/PostgresSceneRepository.java | 6 + .../web/controller/CharacterController.java | 30 +++- .../web/controller/EnemyController.java | 79 +++++++++ .../web/controller/GameSystemController.java | 1 + .../web/controller/ItemCatalogController.java | 8 + .../web/controller/NpcController.java | 9 + .../web/controller/RandomTableController.java | 8 + .../web/dto/campaigncontext/RoomDTO.java | 2 + .../web/dto/campaigncontext/SceneDTO.java | 3 + .../dto/gamesystemcontext/GameSystemDTO.java | 1 + .../web/mapper/GameSystemMapper.java | 2 + .../web/mapper/SceneMapper.java | 12 ++ .../CampaignStructuralContextTest.java | 4 +- .../ai/BrainChatPayloadBuilderTest.java | 8 +- web/package-lock.json | 4 +- web/package.json | 2 +- web/src/app/app.routes.ts | 5 + .../arc/arc-create/arc-create.component.ts | 4 +- .../arc/arc-edit/arc-edit.component.ts | 4 +- .../arc/arc-view/arc-view.component.ts | 4 +- web/src/app/campaigns/campaign-tree.helper.ts | 78 +++++++- .../campaign-detail.component.ts | 10 +- .../campaign-import.component.ts | 4 +- .../chapter-create.component.ts | 4 +- .../chapter-edit/chapter-edit.component.ts | 4 +- .../chapter-graph/chapter-graph.component.ts | 4 +- .../chapter-view/chapter-view.component.ts | 4 +- .../enemy-edit/enemy-edit.component.html | 111 ++++++++++++ .../enemy-edit/enemy-edit.component.scss | 157 ++++++++++++++++ .../enemy/enemy-edit/enemy-edit.component.ts | 167 ++++++++++++++++++ .../enemy-list/enemy-list.component.html | 48 +++++ .../enemy-list/enemy-list.component.scss | 53 ++++++ .../enemy/enemy-list/enemy-list.component.ts | 107 +++++++++++ .../enemy-view/enemy-view.component.html | 15 ++ .../enemy-view/enemy-view.component.scss | 94 ++++++++++ .../enemy/enemy-view/enemy-view.component.ts | 99 +++++++++++ .../notebook-action-card.component.ts | 35 +++- .../notebook-detail.component.ts | 4 +- .../npc/npc-list/npc-list.component.html | 45 +++++ .../npc/npc-list/npc-list.component.scss | 53 ++++++ .../npc/npc-list/npc-list.component.ts | 109 ++++++++++++ .../playthrough-detail.component.ts | 4 +- .../playthrough-flags-page.component.ts | 4 +- .../scene-create/scene-create.component.ts | 4 +- .../scene-edit/scene-edit.component.html | 14 +- .../scene/scene-edit/scene-edit.component.ts | 14 +- .../scene-view/scene-view.component.html | 18 +- .../scene/scene-view/scene-view.component.ts | 21 ++- .../game-system-edit.component.html | 8 + .../game-system-edit.component.ts | 11 +- .../app/services/campaign-sidebar.service.ts | 5 +- web/src/app/services/campaign.model.ts | 8 + web/src/app/services/character.service.ts | 16 +- web/src/app/services/enemy.model.ts | 32 ++++ web/src/app/services/enemy.service.ts | 39 ++++ web/src/app/services/game-system.model.ts | 2 + web/src/app/services/item-catalog.service.ts | 5 + web/src/app/services/notebook-action.model.ts | 27 +++ web/src/app/services/npc.service.ts | 5 + web/src/app/services/random-table.service.ts | 5 + .../session-reference-panel.component.ts | 8 +- .../enemy-link-picker.component.html | 52 ++++++ .../enemy-link-picker.component.scss | 114 ++++++++++++ .../enemy-link-picker.component.ts | 92 ++++++++++ .../global-search/global-search.component.ts | 108 +++++++++-- .../rooms-editor/rooms-editor.component.html | 11 +- .../rooms-editor/rooms-editor.component.ts | 8 +- 105 files changed, 2652 insertions(+), 99 deletions(-) create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/EnemyRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/EnemyJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/EnemyController.java create mode 100644 web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html create mode 100644 web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss create mode 100644 web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts create mode 100644 web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html create mode 100644 web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss create mode 100644 web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts create mode 100644 web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html create mode 100644 web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss create mode 100644 web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts create mode 100644 web/src/app/campaigns/npc/npc-list/npc-list.component.html create mode 100644 web/src/app/campaigns/npc/npc-list/npc-list.component.scss create mode 100644 web/src/app/campaigns/npc/npc-list/npc-list.component.ts create mode 100644 web/src/app/services/enemy.model.ts create mode 100644 web/src/app/services/enemy.service.ts create mode 100644 web/src/app/shared/enemy-link-picker/enemy-link-picker.component.html create mode 100644 web/src/app/shared/enemy-link-picker/enemy-link-picker.component.scss create mode 100644 web/src/app/shared/enemy-link-picker/enemy-link-picker.component.ts diff --git a/brain/app/application/notebook_chat.py b/brain/app/application/notebook_chat.py index 2db8b51..15ef2ff 100644 --- a/brain/app/application/notebook_chat.py +++ b/brain/app/application/notebook_chat.py @@ -34,23 +34,59 @@ Règles : PROPOSITIONS D'INTÉGRATION (IMPORTANT) : 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 -plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture ```loremind-action. -L'interface les transformera en boutons « Créer dans la campagne ». N'en mets que si -c'est pertinent et explicitement souhaité. Formats acceptés : +une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par +un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture +```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ». +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 -{{"type": "npc", "name": "Nom", "description": "Fiche en quelques phrases."}} +{{"type": "npc", "name": "Nom", + "description": "Résumé du PNJ (rôle, apparence, motivation).", + "values": {{"": "contenu", "": "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 -{{"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 -{{"type": "chapter", "name": "Nom", "description": "Résumé du chapitre."}} -``` -```loremind-action -{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR"}} +{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR", + "themes": "Thèmes de l'arc", "stakes": "Enjeux", + "rewards": "Récompenses attendues", "resolution": "Issues possibles", + "gmNotes": "Notes MJ."}} ``` +(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de +quêtes parallèles.) ```loremind-action {{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}} ``` diff --git a/brain/app/main.py b/brain/app/main.py index 86bc751..0a21369 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo app = FastAPI( title="LoreMind Brain", 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__) diff --git a/core/pom.xml b/core/pom.xml index 40e2dae..71b8600 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.12.6-beta + 0.13.0-beta LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignBriefBuilder.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignBriefBuilder.java index d11b017..fee0b71 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignBriefBuilder.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignBriefBuilder.java @@ -42,15 +42,17 @@ public class CampaignBriefBuilder { if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); sb.append("\n## Structure (arcs → chapitres → scènes)\n"); + sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ") + .append("un arc LINEAR contient des chapitres en séquence._\n"); if (cc.arcs().isEmpty()) { sb.append("_(aucun arc pour le moment)_\n"); } for (ArcSummary arc : cc.arcs()) { - sb.append("### Arc : ").append(arc.name()); + sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name()); if (notBlank(arc.description())) sb.append(" — ").append(arc.description()); sb.append("\n"); for (ChapterSummary ch : arc.chapters()) { - sb.append("- Chapitre : ").append(ch.name()); + sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name()); if (notBlank(ch.description())) sb.append(" — ").append(ch.description()); sb.append("\n"); for (SceneSummary sc : ch.scenes()) { diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java index 8117096..9641457 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java @@ -78,6 +78,11 @@ public class CharacterService { characterRepository.deleteById(id); } + public List searchCharacters(String query) { + if (query == null || query.isBlank()) return List.of(); + return characterRepository.searchByName(query.trim()); + } + private int nextOrderFor(String playthroughId) { return characterRepository.findByPlaythroughId(playthroughId).stream() .mapToInt(Character::getOrder) diff --git a/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java b/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java new file mode 100644 index 0000000..67e9b72 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java @@ -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 values, + Map> imageValues, + Map> 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 getEnemyById(String id) { + return enemyRepository.findById(id); + } + + public List 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 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; + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ItemCatalogService.java b/core/src/main/java/com/loremind/application/campaigncontext/ItemCatalogService.java index 366bf01..0a083a8 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/ItemCatalogService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/ItemCatalogService.java @@ -82,6 +82,11 @@ public class ItemCatalogService { repository.deleteById(id); } + public List searchCatalogs(String query) { + if (query == null || query.isBlank()) return List.of(); + return repository.searchByName(query.trim()); + } + /** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */ public ItemCatalog generateProposal(String campaignId, String description) { ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId)); diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java b/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java index 7b20e11..a945b58 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java @@ -22,16 +22,19 @@ public class NotebookService { private final NotebookIndexer indexer; private final CampaignRepository campaignRepository; private final CampaignBriefBuilder briefBuilder; + private final com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository; public NotebookService( NotebookRepository repository, NotebookIndexer indexer, CampaignRepository campaignRepository, - CampaignBriefBuilder briefBuilder) { + CampaignBriefBuilder briefBuilder, + com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository) { this.repository = repository; this.indexer = indexer; this.campaignRepository = campaignRepository; this.briefBuilder = briefBuilder; + this.gameSystemRepository = gameSystemRepository; } // --- Notebooks --- @@ -179,6 +182,25 @@ public class NotebookService { if (campaignId == null) return ""; Campaign campaign = campaignRepository.findById(campaignId).orElse(null); if (campaign == null) return ""; - return briefBuilder.build(campaign); + String brief = briefBuilder.build(campaign); + // Champs TEXT de la fiche PNJ du système de jeu : permet à l'IA de remplir + // `values` des actions "npc" avec les BONS noms de champs (Histoire, + // Apparence…) au lieu de tout entasser dans une description générique. + String npcFields = npcSheetFields(campaign.getGameSystemId()); + return npcFields.isEmpty() ? brief : brief + "\n\n" + npcFields; + } + + private String npcSheetFields(String gameSystemId) { + if (gameSystemId == null || gameSystemId.isBlank()) return ""; + var gameSystem = gameSystemRepository.findById(gameSystemId).orElse(null); + if (gameSystem == null || gameSystem.getNpcTemplate() == null) return ""; + var names = gameSystem.getNpcTemplate().stream() + .filter(f -> f.getType() == com.loremind.domain.shared.template.FieldType.TEXT) + .map(com.loremind.domain.shared.template.TemplateField::getName) + .filter(n -> n != null && !n.isBlank()) + .toList(); + if (names.isEmpty()) return ""; + return "FICHE PNJ — champs texte disponibles (clés à utiliser dans `values` " + + "d'une action npc) : " + String.join(", ", names); } } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java index e7361e2..18de3ab 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java @@ -102,6 +102,11 @@ public class NpcService { npcRepository.deleteById(id); } + public List searchNpcs(String query) { + if (query == null || query.isBlank()) return List.of(); + return npcRepository.searchByName(query.trim()); + } + /** Trim le dossier ; chaîne vide → null (= non classé). */ private static String normalizeFolder(String folder) { if (folder == null) return null; diff --git a/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java b/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java index 3fb8599..0194dc2 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java @@ -85,6 +85,11 @@ public class RandomTableService { repository.deleteById(id); } + public List searchTables(String query) { + if (query == null || query.isBlank()) return List.of(); + return repository.searchByName(query.trim()); + } + /** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */ public RandomTable generateProposal(String campaignId, String description, String diceFormula) { String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula; diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java index 0d6f211..25f499a 100644 --- a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java +++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java @@ -57,6 +57,7 @@ public class GameSystemService { String rulesMarkdown, List characterTemplate, List npcTemplate, + List enemyTemplate, String author, boolean isPublic ) {} @@ -71,6 +72,7 @@ public class GameSystemService { .build(); gameSystem.replaceCharacterTemplate(data.characterTemplate()); gameSystem.replaceNpcTemplate(data.npcTemplate()); + gameSystem.replaceEnemyTemplate(data.enemyTemplate()); return gameSystemRepository.save(gameSystem); } @@ -90,6 +92,7 @@ public class GameSystemService { existing.setRulesMarkdown(data.rulesMarkdown()); existing.replaceCharacterTemplate(data.characterTemplate()); existing.replaceNpcTemplate(data.npcTemplate()); + existing.replaceEnemyTemplate(data.enemyTemplate()); existing.setAuthor(normalize(data.author())); existing.setPublic(data.isPublic()); return gameSystemRepository.save(existing); diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java index 53bb13d..3298ac3 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java @@ -1,6 +1,7 @@ package com.loremind.application.generationcontext; import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Character; @@ -10,6 +11,7 @@ import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository; +import com.loremind.domain.campaigncontext.ports.EnemyRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.generationcontext.CampaignStructuralContext; @@ -48,6 +50,7 @@ public class CampaignStructuralContextBuilder { private final SceneRepository sceneRepository; private final CharacterRepository characterRepository; private final NpcRepository npcRepository; + private final EnemyRepository enemyRepository; public CampaignStructuralContextBuilder( CampaignRepository campaignRepository, @@ -55,13 +58,15 @@ public class CampaignStructuralContextBuilder { ChapterRepository chapterRepository, SceneRepository sceneRepository, CharacterRepository characterRepository, - NpcRepository npcRepository) { + NpcRepository npcRepository, + EnemyRepository enemyRepository) { this.campaignRepository = campaignRepository; this.arcRepository = arcRepository; this.chapterRepository = chapterRepository; this.sceneRepository = sceneRepository; this.characterRepository = characterRepository; this.npcRepository = npcRepository; + this.enemyRepository = enemyRepository; } /** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */ @@ -84,9 +89,17 @@ public class CampaignStructuralContextBuilder { .orElseThrow(() -> new IllegalArgumentException( "Campagne non trouvée avec l'ID: " + campaignId)); + // Libellés du bestiaire (« Nom (niveau) ») chargés UNE fois pour résoudre + // les enemyIds des pièces sans N+1 sur le repo. + Map enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream() + .collect(Collectors.toMap( + com.loremind.domain.campaigncontext.Enemy::getId, + CampaignStructuralContextBuilder::enemyLabel, + (a, b) -> a)); + List arcs = arcRepository.findByCampaignId(campaignId).stream() .sorted(Comparator.comparingInt(Arc::getOrder)) - .map(this::toArcSummary) + .map(arc -> toArcSummary(arc, enemyLabelById)) .collect(Collectors.toList()); List characters = (playthroughId == null || playthroughId.isBlank()) @@ -143,19 +156,20 @@ public class CampaignStructuralContextBuilder { return ""; } - private ArcSummary toArcSummary(Arc arc) { + private ArcSummary toArcSummary(Arc arc, Map enemyLabelById) { List chapters = chapterRepository.findByArcId(arc.getId()).stream() .sorted(Comparator.comparingInt(Chapter::getOrder)) - .map(this::toChapterSummary) + .map(chapter -> toChapterSummary(chapter, enemyLabelById)) .collect(Collectors.toList()); return new ArcSummary( arc.getName(), arc.getDescription(), + arc.getType() == ArcType.HUB, countImages(arc.getIllustrationImageIds()), chapters); } - private ChapterSummary toChapterSummary(Chapter chapter) { + private ChapterSummary toChapterSummary(Chapter chapter, Map enemyLabelById) { List scenes = sceneRepository.findByChapterId(chapter.getId()).stream() .sorted(Comparator.comparingInt(Scene::getOrder)) .toList(); @@ -166,7 +180,7 @@ public class CampaignStructuralContextBuilder { .collect(Collectors.toMap(Scene::getId, Scene::getName)); List summaries = scenes.stream() - .map(s -> toSceneSummary(s, nameById)) + .map(s -> toSceneSummary(s, nameById, enemyLabelById)) .collect(Collectors.toList()); return new ChapterSummary( @@ -176,7 +190,8 @@ public class CampaignStructuralContextBuilder { summaries); } - private SceneSummary toSceneSummary(Scene scene, Map nameById) { + private SceneSummary toSceneSummary( + Scene scene, Map nameById, Map enemyLabelById) { List hints = scene.getBranches() == null ? List.of() : scene.getBranches().stream() @@ -186,7 +201,7 @@ public class CampaignStructuralContextBuilder { b.condition())) .collect(Collectors.toList()); - List rooms = toRoomSummaries(scene); + List rooms = toRoomSummaries(scene, enemyLabelById); return new SceneSummary( scene.getName(), @@ -202,7 +217,7 @@ public class CampaignStructuralContextBuilder { * connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est * suffisant pour proposer de la narration ou anticiper les choix. */ - private List toRoomSummaries(Scene scene) { + private List toRoomSummaries(Scene scene, Map enemyLabelById) { if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of(); Map nameById = scene.getRooms().stream() .collect(Collectors.toMap( @@ -219,11 +234,36 @@ public class CampaignStructuralContextBuilder { nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"), b.condition())) .collect(Collectors.toList()); - return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints); + return new RoomSummary( + r.getName(), r.getFloor(), r.getDescription(), + roomEnemiesText(r, enemyLabelById), hints); }) .collect(Collectors.toList()); } + /** + * Texte « ennemis » d'une pièce pour le prompt : fiches du bestiaire + * référencées (libellés résolus, IDs orphelins ignorés) suivies du texte + * libre. L'un ou l'autre peut être vide. + */ + private static String roomEnemiesText( + com.loremind.domain.campaigncontext.Room room, Map enemyLabelById) { + String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream() + .map(enemyLabelById::get) + .filter(l -> l != null && !l.isBlank()) + .collect(Collectors.joining(", ")); + String freeText = room.getEnemies() == null ? "" : room.getEnemies().strip(); + if (linked.isEmpty()) return freeText; + if (freeText.isEmpty()) return linked; + return linked + " — " + freeText; + } + + /** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */ + private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) { + String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip(); + return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")"; + } + /** Helper defensif : compte les illustrations attachees (null-safe). */ private static int countImages(List ids) { return ids == null ? 0 : ids.size(); diff --git a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java index 6d3e1bb..8fddbf7 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java @@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository; +import com.loremind.domain.campaigncontext.ports.EnemyRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.generationcontext.NarrativeEntityContext; @@ -32,18 +33,21 @@ public class NarrativeEntityContextBuilder { private final SceneRepository sceneRepository; private final CharacterRepository characterRepository; private final NpcRepository npcRepository; + private final EnemyRepository enemyRepository; public NarrativeEntityContextBuilder( ArcRepository arcRepository, ChapterRepository chapterRepository, SceneRepository sceneRepository, CharacterRepository characterRepository, - NpcRepository npcRepository) { + NpcRepository npcRepository, + EnemyRepository enemyRepository) { this.arcRepository = arcRepository; this.chapterRepository = chapterRepository; this.sceneRepository = sceneRepository; this.characterRepository = characterRepository; this.npcRepository = npcRepository; + this.enemyRepository = enemyRepository; } /** @@ -124,10 +128,41 @@ public class NarrativeEntityContextBuilder { putField(fields, "choicesConsequences", s.getChoicesConsequences()); putField(fields, "combatDifficulty", s.getCombatDifficulty()); putField(fields, "enemies", s.getEnemies()); + putField(fields, "linkedEnemies", resolveLinkedEnemies(s)); putField(fields, "gmSecretNotes", s.getGmSecretNotes()); return new NarrativeEntityContext("scene", s.getName(), fields); } + /** + * Résout les fiches du bestiaire référencées par la scène en une ligne par + * ennemi : « Nom (niveau) — champ: valeur ; … ». Valeurs tronquées : le + * contexte focus doit camper la rencontre, pas embarquer la fiche complète. + * Les IDs orphelins (fiche supprimée) sont ignorés silencieusement. + */ + private String resolveLinkedEnemies(Scene s) { + if (s.getEnemyIds() == null || s.getEnemyIds().isEmpty()) return ""; + StringBuilder sb = new StringBuilder(); + for (String enemyId : s.getEnemyIds()) { + enemyRepository.findById(enemyId).ifPresent(e -> { + if (sb.length() > 0) sb.append("\n"); + sb.append("- ").append(e.getName()); + if (e.getLevel() != null && !e.getLevel().isBlank()) { + sb.append(" (").append(e.getLevel().trim()).append(")"); + } + String stats = e.getValues().entrySet().stream() + .filter(en -> en.getValue() != null && !en.getValue().isBlank()) + .map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100)) + .collect(java.util.stream.Collectors.joining(" ; ")); + if (!stats.isEmpty()) sb.append(" — ").append(stats); + }); + } + return sb.toString(); + } + + private static String truncate(String value, int maxLen) { + return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "…"; + } + private NarrativeEntityContext fromCharacter(Character c) { Map fields = new LinkedHashMap<>(); if (c.getValues() != null) { diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java b/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java new file mode 100644 index 0000000..fedfc9b --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java @@ -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. + *

+ * 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 values; + + /** Valeurs IMAGE du template ennemi (listes d'IDs ordonnées par champ). Jamais null. */ + private Map> imageValues; + + /** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */ + private Map> 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 getValues() { + if (values == null) values = new HashMap<>(); + return values; + } + + public Map> getImageValues() { + if (imageValues == null) imageValues = new HashMap<>(); + return imageValues; + } + + public Map> getKeyValueValues() { + if (keyValueValues == null) keyValueValues = new HashMap<>(); + return keyValueValues; + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Room.java b/core/src/main/java/com/loremind/domain/campaigncontext/Room.java index f8b0100..56f249e 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Room.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Room.java @@ -38,6 +38,13 @@ public class Room { /** Énemis, créatures, boss éventuels (markdown libre). */ private String enemies; + /** + * IDs des fiches du bestiaire ({@link Enemy}) présentes dans la pièce + * (weak refs). Complète le texte libre {@code enemies}, comme sur Scene. + */ + @Builder.Default + private List enemyIds = new ArrayList<>(); + /** Loot / récompenses présentes dans la pièce. */ private String loot; diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java index 6ea62a3..4187298 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java @@ -40,7 +40,15 @@ public class Scene { // === Combat ou rencontre === private String combatDifficulty; // Difficulté estimée - private String enemies; // Liste des ennemis et créatures + private String enemies; // Liste des ennemis et créatures (texte libre) + + /** + * IDs des fiches du bestiaire ({@link Enemy}) engagées dans cette rencontre + * (weak cross-aggregate references). Complète le texte libre `enemies` : + * l'utilisateur peut référencer ses fiches, ou tout écrire à la main, ou les deux. + */ + @Builder.Default + private List enemyIds = new ArrayList<>(); /** * IDs des pages du Lore associées à cette scène (weak cross-context references). diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java index b9c9090..60c6587 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java @@ -19,4 +19,7 @@ public interface CharacterRepository { void deleteById(String id); boolean existsById(String id); + + /** Recherche par nom (insensible à la casse) — alimente la recherche globale. */ + List searchByName(String query); } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/EnemyRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/EnemyRepository.java new file mode 100644 index 0000000..4d7174e --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/EnemyRepository.java @@ -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 findById(String id); + + List findByCampaignId(String campaignId); + + void deleteById(String id); + + /** Recherche par nom (insensible à la casse) — alimente la recherche globale. */ + List searchByName(String query); +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogRepository.java index ef19140..66fe950 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogRepository.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogRepository.java @@ -19,4 +19,7 @@ public interface ItemCatalogRepository { void deleteById(String id); boolean existsById(String id); + + /** Recherche par nom (insensible à la casse) — alimente la recherche globale. */ + List searchByName(String query); } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NpcRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NpcRepository.java index 6470f15..4cdd320 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NpcRepository.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NpcRepository.java @@ -19,4 +19,7 @@ public interface NpcRepository { void deleteById(String id); boolean existsById(String id); + + /** Recherche par nom (insensible à la casse) — alimente la recherche globale. */ + List searchByName(String query); } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableRepository.java index 0292a61..fc2d320 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableRepository.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableRepository.java @@ -19,4 +19,7 @@ public interface RandomTableRepository { void deleteById(String id); boolean existsById(String id); + + /** Recherche par nom (insensible à la casse) — alimente la recherche globale. */ + List searchByName(String query); } diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java index 241e1ba..3897f16 100644 --- a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java @@ -50,6 +50,14 @@ public class GameSystem { */ private List 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 enemyTemplate; + /** Auteur déclaré — futur marketplace. Nullable. */ private String author; @@ -98,6 +106,10 @@ public class GameSystem { npcTemplate = validateAndCopy(fields); } + public void replaceEnemyTemplate(List fields) { + enemyTemplate = validateAndCopy(fields); + } + // --- Helpers privés ---------------------------------------------------- private static List appendField(List current, TemplateField field) { diff --git a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java index d043788..a793f90 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java @@ -52,11 +52,15 @@ public record CampaignStructuralContext( /** * Résumé d'un arc : nom + description courte + ses chapitres. * + * @param hub true si l'arc est de type HUB : ses chapitres sont des + * « quêtes » parallèles (vocabulaire UI). L'IA doit le savoir + * pour parler de quêtes et cibler le bon arc. * @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */ public record ArcSummary( String name, String description, + boolean hub, int illustrationCount, List chapters) { } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java index 3d881b2..8ae190f 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java @@ -252,9 +252,15 @@ public class BrainChatPayloadBuilder { ArcSummary::name, ArcSummary::description, ArcSummary::illustrationCount, - (map, arc) -> map.put("chapters", arc.chapters().stream() - .map(this::chapterSummaryToMap) - .collect(Collectors.toList()))); + (map, arc) -> { + // Vocabulaire UI : les chapitres d'un arc HUB sont des « quêtes ». + if (arc.hub()) { + map.put("arc_type", "HUB"); + } + map.put("chapters", arc.chapters().stream() + .map(this::chapterSummaryToMap) + .collect(Collectors.toList())); + }); } private Map chapterSummaryToMap(ChapterSummary c) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java new file mode 100644 index 0000000..840e98c --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java @@ -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 values; + + @Convert(converter = StringListMapJsonConverter.class) + @Column(name = "image_values", columnDefinition = "TEXT") + private Map> imageValues; + + @Convert(converter = StringMapMapJsonConverter.class) + @Column(name = "key_value_values", columnDefinition = "TEXT") + private Map> 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(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java index 44dd1f8..09cf7c2 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java @@ -46,6 +46,11 @@ public class GameSystemJpaEntity { @Column(name = "npc_template", columnDefinition = "TEXT") private List npcTemplate; + /** Template ENNEMI (bestiaire) serialise en JSON. */ + @Convert(converter = TemplateFieldListJsonConverter.class) + @Column(name = "enemy_template", columnDefinition = "TEXT") + private List enemyTemplate; + @Column private String author; @@ -64,6 +69,7 @@ public class GameSystemJpaEntity { updatedAt = LocalDateTime.now(); if (characterTemplate == null) characterTemplate = new ArrayList<>(); if (npcTemplate == null) npcTemplate = new ArrayList<>(); + if (enemyTemplate == null) enemyTemplate = new ArrayList<>(); } @PreUpdate diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java index 7564629..8733609 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java @@ -75,6 +75,12 @@ public class SceneJpaEntity { @Column(columnDefinition = "TEXT") private String enemies; + /** IDs des fiches du bestiaire liées à la rencontre (JSON, weak refs). */ + @Column(name = "enemy_ids", columnDefinition = "TEXT") + @Convert(converter = StringListJsonConverter.class) + @Builder.Default + private List enemyIds = new ArrayList<>(); + @Column(name = "related_page_ids", columnDefinition = "TEXT") @Convert(converter = StringListJsonConverter.class) @Builder.Default diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java index 7ae0269..1d1824b 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java @@ -10,4 +10,7 @@ import java.util.List; public interface CharacterJpaRepository extends JpaRepository { List findByPlaythroughIdOrderByOrderAsc(Long playthroughId); + + /** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */ + List findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/EnemyJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/EnemyJpaRepository.java new file mode 100644 index 0000000..3f585b3 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/EnemyJpaRepository.java @@ -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 { + + List findByCampaignIdOrderByOrderAsc(Long campaignId); + + /** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */ + List findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ItemCatalogJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ItemCatalogJpaRepository.java index 94f43e0..75445bf 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ItemCatalogJpaRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ItemCatalogJpaRepository.java @@ -10,4 +10,7 @@ import java.util.List; public interface ItemCatalogJpaRepository extends JpaRepository { List findByCampaignIdOrderByOrderAsc(Long campaignId); + + /** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */ + List findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NpcJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NpcJpaRepository.java index ab08d29..03a1a1e 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NpcJpaRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NpcJpaRepository.java @@ -10,4 +10,7 @@ import java.util.List; public interface NpcJpaRepository extends JpaRepository { List findByCampaignIdOrderByOrderAsc(Long campaignId); + + /** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */ + List findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/RandomTableJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/RandomTableJpaRepository.java index 78968db..bb10cf9 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/RandomTableJpaRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/RandomTableJpaRepository.java @@ -10,4 +10,7 @@ import java.util.List; public interface RandomTableJpaRepository extends JpaRepository { List findByCampaignIdOrderByOrderAsc(Long campaignId); + + /** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */ + List findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java index e444ffa..4e060e4 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java @@ -49,6 +49,13 @@ public class PostgresCharacterRepository implements CharacterRepository { return jpaRepository.existsById(Long.parseLong(id)); } + @Override + public List searchByName(String query) { + return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + private Character toDomainEntity(CharacterJpaEntity e) { return Character.builder() .id(e.getId().toString()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java new file mode 100644 index 0000000..0d59c86 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java @@ -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 findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity); + } + + @Override + public List 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 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(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java index 8ca4433..cc6491d 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java @@ -67,6 +67,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository { .npcTemplate(e.getNpcTemplate() != null ? new java.util.ArrayList<>(e.getNpcTemplate()) : new java.util.ArrayList<>()) + .enemyTemplate(e.getEnemyTemplate() != null + ? new java.util.ArrayList<>(e.getEnemyTemplate()) + : new java.util.ArrayList<>()) .author(e.getAuthor()) .isPublic(e.isPublic()) .createdAt(e.getCreatedAt()) @@ -87,6 +90,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository { .npcTemplate(g.getNpcTemplate() != null ? new java.util.ArrayList<>(g.getNpcTemplate()) : new java.util.ArrayList<>()) + .enemyTemplate(g.getEnemyTemplate() != null + ? new java.util.ArrayList<>(g.getEnemyTemplate()) + : new java.util.ArrayList<>()) .author(g.getAuthor()) .isPublic(g.isPublic()) .createdAt(g.getCreatedAt()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java index f2f49f2..30438cd 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java @@ -77,6 +77,14 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository { return jpaRepository.existsById(Long.parseLong(id)); } + @Override + @Transactional(readOnly = true) + public List searchByName(String query) { + return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) { List items = e.getItems().stream() .map(c -> CatalogItem.builder() diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java index 4a7de8b..2a5fac3 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java @@ -50,6 +50,13 @@ public class PostgresNpcRepository implements NpcRepository { return jpaRepository.existsById(Long.parseLong(id)); } + @Override + public List searchByName(String query) { + return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + private Npc toDomainEntity(NpcJpaEntity e) { return Npc.builder() .id(e.getId().toString()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java index cf75236..8453eb1 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java @@ -81,6 +81,14 @@ public class PostgresRandomTableRepository implements RandomTableRepository { return jpaRepository.existsById(Long.parseLong(id)); } + @Override + @Transactional(readOnly = true) + public List searchByName(String query) { + return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + private RandomTable toDomainEntity(RandomTableJpaEntity e) { List entries = e.getEntries().stream() .map(c -> RandomTableEntry.builder() diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java index 91646f4..84bb9b7 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java @@ -80,6 +80,9 @@ public class PostgresSceneRepository implements SceneRepository { .choicesConsequences(jpaEntity.getChoicesConsequences()) .combatDifficulty(jpaEntity.getCombatDifficulty()) .enemies(jpaEntity.getEnemies()) + .enemyIds(jpaEntity.getEnemyIds() != null + ? new ArrayList<>(jpaEntity.getEnemyIds()) + : new ArrayList<>()) .relatedPageIds(jpaEntity.getRelatedPageIds() != null ? new ArrayList<>(jpaEntity.getRelatedPageIds()) : new ArrayList<>()) @@ -117,6 +120,9 @@ public class PostgresSceneRepository implements SceneRepository { .choicesConsequences(scene.getChoicesConsequences()) .combatDifficulty(scene.getCombatDifficulty()) .enemies(scene.getEnemies()) + .enemyIds(scene.getEnemyIds() != null + ? new ArrayList<>(scene.getEnemyIds()) + : new ArrayList<>()) .relatedPageIds(scene.getRelatedPageIds() != null ? new ArrayList<>(scene.getRelatedPageIds()) : new ArrayList<>()) diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java index 5e53718..fbe66ad 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java @@ -16,10 +16,13 @@ public class CharacterController { private final CharacterService characterService; private final CharacterMapper characterMapper; + private final com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository; - public CharacterController(CharacterService characterService, CharacterMapper characterMapper) { + public CharacterController(CharacterService characterService, CharacterMapper characterMapper, + com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository) { this.characterService = characterService; this.characterMapper = characterMapper; + this.playthroughRepository = playthroughRepository; } @PostMapping @@ -43,6 +46,31 @@ public class CharacterController { return ResponseEntity.ok(dtos); } + /** + * Recherche par nom — alimente la recherche globale (Ctrl+K). Le résultat est + * enrichi du campaignId (résolu via le Playthrough) pour que le front puisse + * construire la route /campaigns/{c}/playthroughs/{p}/characters/{id}. + */ + @GetMapping("/search") + public ResponseEntity> search(@RequestParam("q") String query) { + List out = characterService.searchCharacters(query).stream() + .map(c -> new CharacterSearchDTO( + c.getId(), + c.getName(), + c.getPlaythroughId(), + c.getPlaythroughId() != null + ? playthroughRepository.findById(c.getPlaythroughId()) + .map(com.loremind.domain.playcontext.Playthrough::getCampaignId) + .orElse(null) + : null)) + .filter(r -> r.campaignId() != null) // PJ orphelin (legacy) : non navigable → exclu + .collect(Collectors.toList()); + return ResponseEntity.ok(out); + } + + /** Résultat de recherche d'un PJ, enrichi pour la navigation. */ + public record CharacterSearchDTO(String id, String name, String playthroughId, String campaignId) {} + @PutMapping("/{id}") public ResponseEntity updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) { Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder())); diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/EnemyController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/EnemyController.java new file mode 100644 index 0000000..9d294c1 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/EnemyController.java @@ -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 create(@RequestBody EnemyRequest req) { + return ResponseEntity.ok(enemyService.createEnemy(toData(req))); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable String id) { + return enemyService.getEnemyById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/campaign/{campaignId}") + public ResponseEntity> getByCampaign(@PathVariable String campaignId) { + return ResponseEntity.ok(enemyService.getEnemiesByCampaignId(campaignId)); + } + + /** Recherche par nom — alimente la recherche globale (Ctrl+K). */ + @GetMapping("/search") + public ResponseEntity> search(@RequestParam("q") String query) { + return ResponseEntity.ok(enemyService.searchEnemies(query)); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable String id, @RequestBody EnemyRequest req) { + return ResponseEntity.ok(enemyService.updateEnemy(id, toData(req))); + } + + @DeleteMapping("/{id}") + public ResponseEntity 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 values, + Map> imageValues, + Map> keyValueValues, + String campaignId, + Integer order) {} +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java index c1a7c36..c30d5fd 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java @@ -263,6 +263,7 @@ public class GameSystemController { dto.getRulesMarkdown(), toDomainFields(dto.getCharacterTemplate()), toDomainFields(dto.getNpcTemplate()), + toDomainFields(dto.getEnemyTemplate()), dto.getAuthor(), dto.isPublic() ); diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ItemCatalogController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ItemCatalogController.java index 941ca95..f5b158a 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ItemCatalogController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ItemCatalogController.java @@ -58,6 +58,14 @@ public class ItemCatalogController { return ResponseEntity.noContent().build(); } + /** Recherche par nom — alimente la recherche globale (Ctrl+K). */ + @GetMapping("/search") + public ResponseEntity> search(@RequestParam("q") String query) { + return ResponseEntity.ok(service.searchCatalogs(query).stream() + .map(mapper::toDTO) + .collect(java.util.stream.Collectors.toList())); + } + /** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */ @PostMapping("/generate") public ResponseEntity generate(@RequestBody GenerateRequest req) { diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java index b473b37..3a78182 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java @@ -43,6 +43,15 @@ public class NpcController { return ResponseEntity.ok(dtos); } + /** Recherche par nom — alimente la recherche globale (Ctrl+K). */ + @GetMapping("/search") + public ResponseEntity> search(@RequestParam("q") String query) { + List dtos = npcService.searchNpcs(query).stream() + .map(npcMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + /** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */ @GetMapping("/lore/{loreId}") public ResponseEntity> getNpcsByLore(@PathVariable String loreId) { diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java index dcdf5e4..9de32f8 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java @@ -59,6 +59,14 @@ public class RandomTableController { return ResponseEntity.noContent().build(); } + /** Recherche par nom — alimente la recherche globale (Ctrl+K). */ + @GetMapping("/search") + public ResponseEntity> search(@RequestParam("q") String query) { + return ResponseEntity.ok(service.searchTables(query).stream() + .map(mapper::toDTO) + .collect(java.util.stream.Collectors.toList())); + } + /** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */ @PostMapping("/generate") public ResponseEntity generate(@RequestBody GenerateRequest req) { diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomDTO.java index 37cc135..7dda2d8 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomDTO.java @@ -16,6 +16,8 @@ public class RoomDTO { private String name; private String description; private String enemies; + /** IDs des fiches du bestiaire présentes dans la pièce (weak refs). */ + private List enemyIds = new ArrayList<>(); private String loot; private String traps; private String gmNotes; diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java index 154f9cf..babdf96 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java @@ -30,6 +30,9 @@ public class SceneDTO { private String combatDifficulty; private String enemies; + /** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */ + private List enemyIds = new ArrayList<>(); + /** IDs des pages du Lore liées (weak cross-context references). */ private List relatedPageIds = new ArrayList<>(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java index dab9cde..d1acc32 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java @@ -19,6 +19,7 @@ public class GameSystemDTO { private String rulesMarkdown; private List characterTemplate = new ArrayList<>(); private List npcTemplate = new ArrayList<>(); + private List enemyTemplate = new ArrayList<>(); private String author; private boolean isPublic; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java index c7f958c..eb19bc8 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java @@ -27,6 +27,7 @@ public class GameSystemMapper { dto.setRulesMarkdown(g.getRulesMarkdown()); dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate())); dto.setNpcTemplate(toDTOList(g.getNpcTemplate())); + dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate())); dto.setAuthor(g.getAuthor()); dto.setPublic(g.isPublic()); return dto; @@ -41,6 +42,7 @@ public class GameSystemMapper { .rulesMarkdown(dto.getRulesMarkdown()) .characterTemplate(toDomainList(dto.getCharacterTemplate())) .npcTemplate(toDomainList(dto.getNpcTemplate())) + .enemyTemplate(toDomainList(dto.getEnemyTemplate())) .author(dto.getAuthor()) .isPublic(dto.isPublic()) .build(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java index 2b3049c..1d09a68 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java @@ -40,6 +40,9 @@ public class SceneMapper { dto.setChoicesConsequences(scene.getChoicesConsequences()); dto.setCombatDifficulty(scene.getCombatDifficulty()); dto.setEnemies(scene.getEnemies()); + dto.setEnemyIds(scene.getEnemyIds() != null + ? new ArrayList<>(scene.getEnemyIds()) + : new ArrayList<>()); dto.setRelatedPageIds(scene.getRelatedPageIds() != null ? new ArrayList<>(scene.getRelatedPageIds()) : new ArrayList<>()); @@ -74,6 +77,9 @@ public class SceneMapper { .choicesConsequences(dto.getChoicesConsequences()) .combatDifficulty(dto.getCombatDifficulty()) .enemies(dto.getEnemies()) + .enemyIds(dto.getEnemyIds() != null + ? new ArrayList<>(dto.getEnemyIds()) + : new ArrayList<>()) .relatedPageIds(dto.getRelatedPageIds() != null ? new ArrayList<>(dto.getRelatedPageIds()) : new ArrayList<>()) @@ -117,6 +123,9 @@ public class SceneMapper { dto.setName(r.getName()); dto.setDescription(r.getDescription()); dto.setEnemies(r.getEnemies()); + dto.setEnemyIds(r.getEnemyIds() != null + ? new ArrayList<>(r.getEnemyIds()) + : new ArrayList<>()); dto.setLoot(r.getLoot()); dto.setTraps(r.getTraps()); dto.setGmNotes(r.getGmNotes()); @@ -145,6 +154,9 @@ public class SceneMapper { .name(d.getName()) .description(d.getDescription()) .enemies(d.getEnemies()) + .enemyIds(d.getEnemyIds() != null + ? new ArrayList<>(d.getEnemyIds()) + : new ArrayList<>()) .loot(d.getLoot()) .traps(d.getTraps()) .gmNotes(d.getGmNotes()) diff --git a/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java index 175b0fd..7472ed5 100644 --- a/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java +++ b/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java @@ -37,6 +37,7 @@ class CampaignStructuralContextTest { ArcSummary arc = new ArcSummary( "Acte I", "Mise en place", + false, 1, List.of(chapter)); @@ -76,7 +77,7 @@ class CampaignStructuralContextTest { @Test void illustrationCount_defaultsToZero_onAllSummaryTypes() { - ArcSummary arc = new ArcSummary("X", null, 0, List.of()); + ArcSummary arc = new ArcSummary("X", null, false, 0, List.of()); ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of()); SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of()); @@ -92,6 +93,7 @@ class CampaignStructuralContextTest { ArcSummary arc = new ArcSummary( "Acte I", null, + false, 0, List.of( new ChapterSummary("Ch1", null, 0, List.of()), diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java index 66cf90c..8a131d6 100644 --- a/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java @@ -165,7 +165,7 @@ class BrainChatPayloadBuilderTest { BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%"); SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of()); ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene)); - ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter)); + ArcSummary arc = new ArcSummary("Acte I", "Mise en place", false, 1, List.of(chapter)); CampaignStructuralContext camp = new CampaignStructuralContext( "Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of()); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); @@ -198,7 +198,7 @@ class BrainChatPayloadBuilderTest { @Test @SuppressWarnings("unchecked") void build_arcSummary_omitsIllustrationCount_whenZero() { - ArcSummary arc = new ArcSummary("A", "", 0, List.of()); + ArcSummary arc = new ArcSummary("A", "", false, 0, List.of()); CampaignStructuralContext camp = new CampaignStructuralContext( "X", "", List.of(arc), List.of(), List.of()); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); @@ -215,7 +215,7 @@ class BrainChatPayloadBuilderTest { void build_sceneSummary_omitsBranches_whenEmpty() { SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of()); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); - ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); + ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter)); CampaignStructuralContext camp = new CampaignStructuralContext( "X", "", List.of(arc), List.of(), List.of()); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); @@ -234,7 +234,7 @@ class BrainChatPayloadBuilderTest { BranchHint branch = new BranchHint("X", "Y", " "); SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of()); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); - ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); + ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter)); CampaignStructuralContext camp = new CampaignStructuralContext( "X", "", List.of(arc), List.of(), List.of()); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); diff --git a/web/package-lock.json b/web/package-lock.json index a6128f2..0cabec2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.12.6-beta", + "version": "0.13.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.12.6-beta", + "version": "0.13.0-beta", "dependencies": { "@angular/animations": "^21.2.16", "@angular/common": "^21.2.16", diff --git a/web/package.json b/web/package.json index 229f966..a260bdc 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.12.6-beta", + "version": "0.13.0-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 4478e15..f93cb81 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -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/: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/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/: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) }, @@ -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/: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/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/: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) }, diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts index 41484fb..513376d 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts @@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { LayoutService } from '../../../services/layout.service'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; @@ -41,6 +42,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private layoutService: LayoutService ) { this.form = this.fb.group({ @@ -60,7 +62,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), 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 }) => { this.existingArcCount = treeData.arcs.length; diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index d1457dc..ad7923f 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -78,6 +79,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, @@ -117,7 +119,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), 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( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts index fe50d8f..5e04988 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts @@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -60,6 +61,7 @@ export class ArcViewComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, @@ -83,7 +85,7 @@ export class ArcViewComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), 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( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index 47f948f..9c988f3 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -4,11 +4,13 @@ import { CampaignService } from '../services/campaign.service'; import { CharacterService } from '../services/character.service'; import { NpcService } from '../services/npc.service'; import { RandomTableService } from '../services/random-table.service'; +import { EnemyService } from '../services/enemy.service'; import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service'; import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model'; import { Character } from '../services/character.model'; import { Npc } from '../services/npc.model'; import { RandomTable } from '../services/random-table.model'; +import { Enemy } from '../services/enemy.model'; import { catchError } from 'rxjs/operators'; /** @@ -26,6 +28,7 @@ export interface CampaignTreeData { characters: Character[]; npcs: Npc[]; randomTables: RandomTable[]; + enemies: Enemy[]; } export function loadCampaignTreeData( @@ -35,7 +38,10 @@ export function loadCampaignTreeData( npcService: NpcService, // Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les // 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 { // Note refonte Playthrough : les PJ appartiennent désormais à une Partie, // 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), randomTables: randomTableService ? 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( - switchMap(({ arcs, characters, npcs, randomTables }) => { + switchMap(({ arcs, characters, npcs, randomTables, enemies }) => { 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 => service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters }))) @@ -65,7 +74,7 @@ export function loadCampaignTreeData( }); 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 => service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes }))) @@ -74,7 +83,7 @@ export function loadCampaignTreeData( map(sceneResults => { const scenesByChapter: Record = {}; 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', children: npcChildren, 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). // Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar. 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(); + 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 arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => { @@ -233,6 +279,24 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T 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). const importNode: TreeItem = { id: 'import-pdf-root', @@ -241,7 +305,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T route: `/campaigns/${campaignId}/import` }; - return [...arcNodes, npcsNode, tablesNode, notebooksNode, catalogsNode, importNode]; + return [...arcNodes, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode]; } /** diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts index 9b5b8b5..bc4c072 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts @@ -13,6 +13,7 @@ import { GameSystem } from '../../../services/game-system.model'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { SessionService } from '../../../services/session.service'; import { PlaythroughService } from '../../../services/playthrough.service'; import { Playthrough } from '../../../services/campaign.model'; @@ -95,6 +96,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private sessionService: SessionService, private playthroughService: PlaythroughService, private layoutService: LayoutService, @@ -112,8 +114,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { switchMap(id => forkJoin({ campaign: this.campaignService.getCampaignById(id), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe( - catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData)) + treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe( + catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData)) ), playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) })) @@ -149,8 +151,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(id), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe( - catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData)) + treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe( + catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData)) ), playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) }).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => { diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts index b791005..d61f902 100644 --- a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts @@ -11,6 +11,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { PageTitleService } from '../../../services/page-title.service'; import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model'; @@ -106,6 +107,7 @@ export class CampaignImportComponent implements OnInit { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private campaignSidebar: CampaignSidebarService, private pageTitle: PageTitleService ) {} @@ -117,7 +119,7 @@ export class CampaignImportComponent implements OnInit { // Pré-chargement de l'arborescence existante (pour fusionner à la revue). // 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))) .subscribe(data => this.existingData = data); } diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts index 3ec60ee..837cd6a 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts @@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { LayoutService } from '../../../services/layout.service'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; @@ -43,6 +44,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private layoutService: LayoutService ) { this.form = this.fb.group({ @@ -61,7 +63,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), 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 }) => { const currentArc = treeData.arcs.find(a => a.id === this.arcId); this.arcName = currentArc?.name ?? ''; diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts index 07ced4a..c43117e 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts @@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -92,6 +93,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, @@ -128,7 +130,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), 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( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts index 18600ce..565f679 100644 --- a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts @@ -7,6 +7,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { LayoutService, GlobalItem } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; import { Campaign, Chapter, Scene } from '../../../services/campaign.model'; @@ -71,6 +72,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private layoutService: LayoutService, private pageTitleService: PageTitleService ) {} @@ -90,7 +92,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { allCampaigns: this.campaignService.getAllCampaigns(), chapter: this.campaignService.getChapterById(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 }) => { this.chapter = chapter; this.scenes = scenes; diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts index f564e26..5e4584f 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts @@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -52,6 +53,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, @@ -79,7 +81,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), 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( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html new file mode 100644 index 0000000..ee9a257 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html @@ -0,0 +1,111 @@ +

+ +
+ +
+

+ + {{ enemyId ? 'Éditer l\'ennemi' : 'Nouvel ennemi' }} +

+
+
+ +
+ +
+ + +
+ +
+
+ + +
+
+ + + + @for (f of existingFolders; track f) { + + } + +
+
+ +
+
+ + + +
+
+ + + +
+
+ +
+ + +
+ +
+ + + + @if (enemyId) { + + } +
+ +
+ +
diff --git a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss new file mode 100644 index 0000000..0a69ca5 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss @@ -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); + } +} diff --git a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts new file mode 100644 index 0000000..953f21b --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts @@ -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 = {}; + imageValues: Record = {}; + keyValueValues: Record> = {}; + 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']); + } + } +} diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html new file mode 100644 index 0000000..de78560 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html @@ -0,0 +1,48 @@ +
+
+ + + +
+ +
+

Ennemis

+

+ 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…). +

+
+ +
+ @if (total === 0) { +

Aucun ennemi pour l'instant.

+ } + @for (g of groups; track g.folder) { + @if (g.folder) { +

+ + {{ g.folder }} + {{ g.enemies.length }} +

+ } @else if (groups.length > 1) { +

Non classés

+ } + @for (e of g.enemies; track e.id) { + + } + } +
+
diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss new file mode 100644 index 0000000..501899e --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss @@ -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); } } +} diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts new file mode 100644 index 0000000..dcd06b3 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts @@ -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(); + 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]); + } +} diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html new file mode 100644 index 0000000..88deb4d --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html @@ -0,0 +1,15 @@ +
+
+ + + +
+ + +
diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss new file mode 100644 index 0000000..406bcad --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss @@ -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; + } + } +} diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts new file mode 100644 index 0000000..5532117 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts @@ -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']); + } + } +} diff --git a/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts index 37e63a1..48a640f 100644 --- a/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts +++ b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts @@ -103,10 +103,19 @@ export class NotebookActionCardComponent implements OnInit { } 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 = {}; + 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({ name: this.action.name, campaignId: this.campaignId, - values: this.action.description ? { Description: this.action.description } : {} + values }).subscribe({ next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]), error: (e) => this.fail(e) @@ -119,7 +128,12 @@ export class NotebookActionCardComponent implements OnInit { description: this.action.description, campaignId: this.campaignId, 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({ next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]), error: (e) => this.fail(e) @@ -132,7 +146,10 @@ export class NotebookActionCardComponent implements OnInit { name: this.action.name, description: this.action.description, arcId: this.selectedArcId, - order + order, + playerObjectives: this.action.playerObjectives, + narrativeStakes: this.action.narrativeStakes, + gmNotes: this.action.gmNotes }).subscribe({ next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]), error: (e) => this.fail(e) @@ -143,9 +160,17 @@ export class NotebookActionCardComponent implements OnInit { this.campaignService.createScene({ name: this.action.name, 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, - 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({ next: (s) => this.done( ['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]), diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts index c169c4e..50f05bf 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts @@ -8,6 +8,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { EnemyService } from '../../../services/enemy.service'; import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model'; import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model'; import { Arc, Chapter } from '../../../services/campaign.model'; @@ -66,6 +67,7 @@ export class NotebookDetailComponent implements OnInit { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private enemyService: EnemyService, private confirmDialog: ConfirmDialogService ) {} @@ -80,7 +82,7 @@ export class NotebookDetailComponent implements OnInit { } 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({ next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; }, error: () => { /* cibles indisponibles : les cartes le signaleront */ } diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.html b/web/src/app/campaigns/npc/npc-list/npc-list.component.html new file mode 100644 index 0000000..04c5b10 --- /dev/null +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.html @@ -0,0 +1,45 @@ +
+
+ + + +
+ +
+

PNJ

+

+ Tous les personnages non-joueurs de la campagne, classés par dossier. + Le champ « Dossier » de chaque fiche pilote le classement. +

+
+ +
+ @if (total === 0) { +

Aucun PNJ pour l'instant.

+ } + @for (g of groups; track g.folder) { + @if (g.folder) { +

+ + {{ g.folder }} + {{ g.npcs.length }} +

+ } @else if (groups.length > 1) { +

Non classés

+ } + @for (n of g.npcs; track n.id) { + + } + } +
+
diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.scss b/web/src/app/campaigns/npc/npc-list/npc-list.component.scss new file mode 100644 index 0000000..501899e --- /dev/null +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.scss @@ -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); } } +} diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.ts b/web/src/app/campaigns/npc/npc-list/npc-list.component.ts new file mode 100644 index 0000000..6b236c4 --- /dev/null +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.ts @@ -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(); + 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]); + } +} diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts index 576b848..3f16153 100644 --- a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts @@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PlaythroughService } from '../../../services/playthrough.service'; import { SessionService } from '../../../services/session.service'; import { LayoutService } from '../../../services/layout.service'; @@ -56,6 +57,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private playthroughService: PlaythroughService, private sessionService: SessionService, private layoutService: LayoutService, @@ -79,7 +81,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), 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), sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))), characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))), diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts index f171d00..28ccae4 100644 --- a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts @@ -7,6 +7,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PlaythroughService } from '../../../services/playthrough.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -38,6 +39,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private playthroughService: PlaythroughService, private layoutService: LayoutService, private pageTitleService: PageTitleService @@ -59,7 +61,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), 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) }).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => { this.playthrough = playthrough; diff --git a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts index 5cfe13b..344fae2 100644 --- a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts +++ b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts @@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { LayoutService } from '../../../services/layout.service'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; @@ -42,6 +43,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, + private enemyService: EnemyService, private layoutService: LayoutService ) { this.form = this.fb.group({ @@ -61,7 +63,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), 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 }) => { const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId); this.chapterName = currentChapter?.name ?? ''; diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html index 18d3f8c..2c1907c 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html @@ -202,7 +202,19 @@
- + + + + + Référencez des fiches de votre bestiaire — cliquez sur un chip pour ouvrir la fiche. + +
+
+
+
+ + + +
- +