diff --git a/INSTALL.md b/INSTALL.md deleted file mode 100644 index 09a852e..0000000 --- a/INSTALL.md +++ /dev/null @@ -1,311 +0,0 @@ -# Installation de LoreMindMJ - -Ce document decrit la procedure d'installation de LoreMindMJ. Temps estime : -5 a 10 minutes selon la qualite de la connexion reseau. - -## 1. Prerequis - -- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) / - [Mac](https://www.docker.com/products/docker-desktop/)) ou - **Docker Engine + Compose v2** (Linux). Verification : - ``` - docker --version - docker compose version - ``` - Compose v2 est requis : la commande est `docker compose`, non `docker-compose`. - -- **Un fournisseur LLM**, au choix : - - **[Ollama](https://ollama.com/)** installe sur la machine hote (gratuit, - local, necessite environ 6 Go de RAM libre pour les modeles recommandes). - - **Une cle API [1min.ai](https://1min.ai)** (hebergement cloud, facturation - a l'usage, aucune installation supplementaire requise). - -- Environ **2 Go d'espace disque** pour les images Docker, auxquels s'ajoute - la taille des modeles Ollama si l'option locale est retenue. - -## 2. Recuperation des fichiers - -Telecharger les deux fichiers suivants depuis la -[derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) et -les placer dans un dossier dedie (par exemple `~/loremind/` ou -`C:\Programs\loremind\`) : - -- `docker-compose.yml` -- `.env.example` - -Le code source n'est pas necessaire : les images sont pre-construites et -publiees sur le registry Gitea `git.igmlcreation.fr` (non Docker Hub). Le -premier `docker compose pull` les telechargera automatiquement. - -## 3. Configuration du fichier `.env` - -Renommer `.env.example` en `.env` et l'ouvrir dans un editeur de texte. **Trois -variables sont obligatoires** ; sans elles, `docker compose up` refusera de -demarrer. Ce comportement est volontaire afin d'eviter tout deploiement -non-securise par defaut. - -### `POSTGRES_PASSWORD` - -Mot de passe de la base de donnees PostgreSQL. Choisir une valeur robuste. -Seuls les conteneurs utilisent cette valeur : il n'est pas necessaire de la -memoriser au-dela du fichier `.env`. - -### `ADMIN_PASSWORD` - -Protege l'ecran **Parametres** de l'application via HTTP Basic. Cette valeur -sera demandee par le navigateur lors de toute modification de la configuration -(changement de modele LLM, saisie de cle API, etc.). Le nom d'utilisateur par -defaut est `admin`, modifiable via la variable `ADMIN_USERNAME`. - -### `BRAIN_INTERNAL_SECRET` - -Secret partage entre le service Java (`core`) et le service Python (`brain`). -Empeche toute requete externe d'atteindre directement le service Brain. -Generer une valeur aleatoire de 64 caracteres hexadecimaux : - -``` -openssl rand -hex 32 -``` - -Sous Windows sans `openssl`, utiliser PowerShell : - -```powershell --join ((48..57) + (97..102) | Get-Random -Count 64 | % {[char]$_}) -``` - -### Variables optionnelles - -- `WEB_PORT` (defaut `8081`) : port d'ecoute de l'interface web. -- `ADMIN_USERNAME` (defaut `admin`) : identifiant de la popup Parametres. -- `LLM_PROVIDER` (defaut `ollama`) : choix du fournisseur LLM (voir - section 5). - -Les autres variables (`MINIO_USER`/`MINIO_PASSWORD`, `POSTGRES_DB`, -`POSTGRES_USER`) disposent de valeurs par defaut adaptees a un deploiement -personnel et peuvent etre conservees en l'etat. - -## 4. Lancement de la stack - -Depuis le dossier contenant `docker-compose.yml` et `.env` : - -``` -docker compose up -d -``` - -Le premier demarrage telecharge les images (environ 1 a 2 Go au total) et -initialise la base. Compter 2 a 5 minutes selon la qualite de la connexion. -La progression peut etre suivie via : - -``` -docker compose logs -f -``` - -(`Ctrl+C` pour quitter l'affichage ; les services continuent de fonctionner -en arriere-plan.) - -Une fois les services en etat `healthy`, ouvrir **http://localhost:8081** -dans un navigateur. - -### Verification du fonctionnement - -``` -docker compose ps -``` - -Cinq conteneurs doivent apparaitre en etat `Up` ou `healthy` : -`loremind-postgres`, `loremind-minio`, `loremind-core`, `loremind-brain`, -`loremind-web`. Le conteneur `loremind-minio-init` s'arrete automatiquement -apres creation du bucket d'images : ce comportement est normal. - -## 5. Configuration du fournisseur LLM - -### Ollama (local, gratuit) - -Installer Ollama sur la machine hote (pas dans Docker), puis telecharger un -modele : - -``` -ollama pull gemma4:26b -``` - -Dans `.env` : - -``` -LLM_PROVIDER=ollama -LLM_MODEL=gemma4:26b -OLLAMA_BASE_URL=http://host.docker.internal:11434 -``` - -L'adresse `host.docker.internal` permet au conteneur `brain` d'atteindre -Ollama sur la machine hote. Cette resolution est native sous Docker Desktop -(Mac / Windows). Sous Linux, le fichier `docker-compose.yml` declare un -`extra_hosts` equivalent. - -### 1min.ai (cloud, paye) - -Dans `.env` : - -``` -LLM_PROVIDER=onemin -ONEMIN_API_KEY=sk-... -ONEMIN_MODEL=gpt-4o-mini -``` - -### Modification a chaud - -Le fournisseur, le modele et la cle API peuvent etre modifies a chaud depuis -l'ecran **Parametres** de l'application. Les modifications sont persistees -dans un volume Docker et survivent aux redemarrages. Les variables d'env du -fichier `.env` sont uniquement utilisees comme valeurs initiales au premier -demarrage. - -## 6. Mise a jour - -``` -docker compose pull -docker compose up -d -``` - -Les donnees (base PostgreSQL, images MinIO, configuration Brain) sont -stockees dans des volumes Docker et survivent aux mises a jour. - -## 7. Sauvegarde - -Les donnees sont reparties dans trois volumes Docker : - -- `loremindmj_postgres-data` — ensemble des donnees applicatives (lores, - campagnes, pages, templates, branches narratives, etc.). -- `loremindmj_minio-data` — images uploadees. -- `loremindmj_brain-data` — parametres IA (fournisseur courant, cle API - 1min.ai). - -### Export SQL de la base - -``` -docker compose exec postgres pg_dump -U loremind loremind > backup.sql -``` - -### Sauvegarde complete des volumes - -Arreter la stack au prealable afin de garantir la coherence des donnees : - -``` -docker compose stop -docker run --rm -v loremindmj_postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-data.tar.gz -C /data . -docker run --rm -v loremindmj_minio-data:/data -v $(pwd):/backup alpine tar czf /backup/minio-data.tar.gz -C /data . -docker compose start -``` - -Sous Windows PowerShell, remplacer `$(pwd)` par `${PWD}`. - -## 8. Resolution des problemes - -### Port 8081 deja utilise - -Modifier `WEB_PORT=8082` (ou toute autre valeur libre) dans `.env`, puis -relancer : - -``` -docker compose up -d -``` - -### Erreur "set POSTGRES_PASSWORD in .env" (ou variable equivalente) au lancement - -Une des trois variables obligatoires de l'etape 3 est manquante. Verifier le -contenu du fichier `.env`. - -### Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres - -Comportement attendu : il s'agit de l'authentification HTTP Basic. Utiliser -la valeur de `ADMIN_USERNAME` (par defaut `admin`) et celle de -`ADMIN_PASSWORD`. - -### Erreurs `password authentication failed` en boucle dans les logs Postgres - -Si la variable `POSTGRES_PASSWORD` a ete modifiee apres un premier lancement, -le volume Postgres conserve l'ancien mot de passe (initialise une seule fois). -Deux options : - -- **Redemarrer avec un volume vierge** (entraine la perte des donnees) : - ``` - docker compose down -v - docker compose up -d - ``` -- **Modifier le mot de passe en base** sans toucher au volume : - ``` - docker compose exec postgres psql -U postgres - ``` - Puis dans le prompt `psql` : - ```sql - ALTER USER loremind WITH PASSWORD 'valeur_exacte_du_env'; - \q - ``` - Redemarrer ensuite le Core : `docker compose restart core`. - -### Erreur "502 Bad Gateway" ou message d'erreur IA dans l'interface - -Le service Brain ne parvient pas a contacter le fournisseur LLM. Verifier : - -- **Ollama** : `ollama serve` est-il actif ? Le modele est-il telecharge - (`ollama list`) ? La valeur de `LLM_MODEL` correspond-elle exactement au - nom d'un modele liste ? -- **1min.ai** : la cle API est-elle valide ? Le modele existe-t-il ? -- Consulter les logs du Brain : - ``` - docker compose logs brain - ``` - -### Un service ne demarre pas ou reste en etat `unhealthy` - -Consulter les logs du service concerne : - -``` -docker compose logs -``` - -Services disponibles : `postgres`, `minio`, `core`, `brain`, `web`. - -### Redemarrage d'un service apres modification du `.env` - -``` -docker compose up -d -``` - -Redemarrage complet : `docker compose restart`. - -### Remise a zero complete (PERTE DES DONNEES) - -``` -docker compose down -v -``` - -L'option `-v` supprime les volumes. L'ensemble des lores, campagnes, images -et parametres est perdu de maniere definitive. - -### "No such image" ou "pull access denied" au premier lancement - -Le registry Gitea peut necessiter une authentification selon la visibilite -configuree pour les images. Contacter l'editeur du projet. - -## 9. Exposition reseau des services - -- **Interface web** : http://localhost:8081 (port configurable via - `WEB_PORT`). -- **PostgreSQL** : accessible uniquement via le reseau Docker interne, non - expose vers l'hote. -- **MinIO** : accessible uniquement via le reseau Docker interne. Les images - transitent par le reverse-proxy Java sur `/api/images/{id}/content`. Le - binding `127.0.0.1:9000/9001` defini dans `docker-compose.override.yml` - n'est actif qu'en developpement. -- **Brain Python** : accessible uniquement via le reseau Docker interne. - Toute requete doit porter l'en-tete `X-Internal-Secret`, injectee - automatiquement par le Core Java et jamais exposee au navigateur. - -## 10. Desinstallation - -``` -docker compose down -v -docker image rm git.igmlcreation.fr/ietm64/core git.igmlcreation.fr/ietm64/brain git.igmlcreation.fr/ietm64/web -``` - -Supprimer ensuite le dossier contenant `docker-compose.yml` et `.env`. diff --git a/README.md b/README.md index 8c00c91..a8ae79a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,15 @@ Pour l'installation, consultez le guide dans cette dernière . - Suivi de campagnes : Sessions, actions des joueurs, chronologie - Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates +## Démo + +Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/) + +!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur. + +Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser. +Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai. + ## License LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**. diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py index 3d1144b..9559d5e 100644 --- a/brain/app/application/chat.py +++ b/brain/app/application/chat.py @@ -21,6 +21,7 @@ from app.domain.models import ( ChatMessage, ChapterSummary, CharacterSummary, + NpcSummary, GameSystemContext, LoreStructuralContext, NarrativeEntityContext, @@ -198,10 +199,12 @@ class ChatUseCase: else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)" ) characters_block = ChatUseCase._format_characters(ctx.characters) + npcs_block = ChatUseCase._format_npcs(ctx.npcs) return ( "--- CAMPAGNE COURANTE ---\n" f"Nom : {ctx.campaign_name}{desc}{lore_note}\n" - f"{characters_block}\n" + f"{characters_block}" + f"{npcs_block}\n" "Structure narrative (les flèches → indiquent des transitions de scène " "déclenchées par un choix des joueurs) :\n" f"{arcs_block}" @@ -231,6 +234,33 @@ class ChatUseCase: ) return "\n".join(lines) + "\n" + @staticmethod + def _format_npcs(npcs: list[NpcSummary]) -> str: + """Bloc PNJ — symétrique aux PJ avec sa propre instruction anti-halluci. + + Distinction importante : pour les PNJ, l'IA est ENCOURAGÉE à proposer de + nouveaux PNJ (création créative = OK). En revanche, elle ne doit pas + référencer comme existant un PNJ qui n'est pas dans la liste ci-dessous. + """ + if not npcs: + return ( + "\nPersonnages non-joueurs (PNJ) : aucun défini pour l'instant. " + "Tu peux librement proposer de nouveaux PNJ au MJ, mais ne " + "fais pas comme s'ils existaient déjà dans la campagne.\n" + ) + lines = ["\nPersonnages non-joueurs (PNJ) connus :"] + for n in npcs: + if n.snippet: + lines.append(f"- **{n.name}** — {n.snippet}") + else: + lines.append(f"- **{n.name}** (fiche vide)") + lines.append( + "Pour une fiche complète d'un PNJ existant (apparence, motivations), " + "n'invente rien : demande au MJ d'ouvrir l'éditeur du PNJ. Tu peux " + "en revanche proposer librement de NOUVEAUX PNJ." + ) + return "\n".join(lines) + "\n" + @staticmethod def _format_arcs(arcs: list[ArcSummary]) -> str: if not arcs: @@ -319,7 +349,8 @@ class ChatUseCase: "arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE", - "character": "FICHE DE PERSONNAGE", + "character": "FICHE DE PERSONNAGE (PJ)", + "npc": "FICHE DE PNJ", }.get(ne.entity_type.lower(), ne.entity_type.upper()) if ne.fields: fields_block = "\n".join( diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py index cfbbb96..4b0b3da 100644 --- a/brain/app/domain/models.py +++ b/brain/app/domain/models.py @@ -170,6 +170,7 @@ class CampaignStructuralContext: campaign_description: str | None arcs: list[ArcSummary] characters: list["CharacterSummary"] = field(default_factory=list) + npcs: list["NpcSummary"] = field(default_factory=list) @dataclass(frozen=True) @@ -185,6 +186,19 @@ class CharacterSummary: snippet: str +@dataclass(frozen=True) +class NpcSummary: + """Résumé d'un PNJ : symétrique à CharacterSummary. + + Permet à l'IA de connaître les PNJ d'une campagne (nom + snippet) sans + injecter leurs fiches complètes. Évolution prévue : entity_type="npc" + pour focus sur la fiche complète. + """ + + name: str + snippet: str + + @dataclass(frozen=True) class NarrativeEntityContext: """Contexte d'une entité narrative précise en cours d'édition. diff --git a/brain/app/main.py b/brain/app/main.py index 2558d0f..c91a146 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -23,6 +23,7 @@ from app.domain.models import ( CampaignStructuralContext, ChapterSummary, CharacterSummary, + NpcSummary, ChatMessage, GameSystemContext, LoreStructuralContext, @@ -205,6 +206,13 @@ class CharacterSummaryDTO(BaseModel): snippet: str = "" +class NpcSummaryDTO(BaseModel): + """Résumé d'un PNJ : symétrique à CharacterSummaryDTO.""" + + name: str + snippet: str = "" + + class CampaignContextDTO(BaseModel): """Carte narrative enrichie : arcs → chapitres → scènes avec synopsis.""" @@ -212,12 +220,13 @@ class CampaignContextDTO(BaseModel): campaign_description: str | None = None arcs: list[ArcSummaryDTO] = Field(default_factory=list) characters: list[CharacterSummaryDTO] = Field(default_factory=list) + npcs: list[NpcSummaryDTO] = Field(default_factory=list) class NarrativeEntityDTO(BaseModel): """Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel.""" - entity_type: str = Field(pattern="^(arc|chapter|scene|character)$") + entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$") title: str fields: dict[str, str] = Field(default_factory=dict) @@ -553,11 +562,16 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo CharacterSummary(name=c.name, snippet=c.snippet) for c in dto.characters ] + npcs = [ + NpcSummary(name=n.name, snippet=n.snippet) + for n in dto.npcs + ] return CampaignStructuralContext( campaign_name=dto.campaign_name, campaign_description=dto.campaign_description, arcs=arcs, characters=characters, + npcs=npcs, ) diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java new file mode 100644 index 0000000..ca1f6b2 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java @@ -0,0 +1,71 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Npc; +import com.loremind.domain.campaigncontext.ports.NpcRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * Service d'application pour les fiches de PNJ (campagne). + */ +@Service +public class NpcService { + + private final NpcRepository npcRepository; + + public NpcService(NpcRepository npcRepository) { + this.npcRepository = npcRepository; + } + + /** + * Parameter Object pour la création / mise à jour d'un Npc. + * `order` est fourni par le controller ; si absent, le service le calcule. + */ + public record NpcData(String name, String markdownContent, String campaignId, Integer order) {} + + public Npc createNpc(NpcData data) { + int order = data.order() != null + ? data.order() + : nextOrderFor(data.campaignId()); + Npc npc = Npc.builder() + .name(data.name()) + .markdownContent(data.markdownContent()) + .campaignId(data.campaignId()) + .order(order) + .build(); + return npcRepository.save(npc); + } + + public Optional getNpcById(String id) { + return npcRepository.findById(id); + } + + public List getNpcsByCampaignId(String campaignId) { + return npcRepository.findByCampaignId(campaignId); + } + + public Npc updateNpc(String id, NpcData data) { + Npc existing = npcRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id)); + existing.setName(data.name()); + existing.setMarkdownContent(data.markdownContent()); + if (data.order() != null) { + existing.setOrder(data.order()); + } + return npcRepository.save(existing); + } + + public void deleteNpc(String id) { + npcRepository.deleteById(id); + } + + /** Renvoie la prochaine position libre — append en fin de liste. */ + private int nextOrderFor(String campaignId) { + return npcRepository.findByCampaignId(campaignId).stream() + .mapToInt(Npc::getOrder) + .max() + .orElse(-1) + 1; + } +} 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 28ff306..3511820 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java @@ -4,17 +4,20 @@ import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Character; +import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.Scene; 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.NpcRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint; import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import org.springframework.stereotype.Component; @@ -42,21 +45,24 @@ public class CampaignStructuralContextBuilder { private final ChapterRepository chapterRepository; private final SceneRepository sceneRepository; private final CharacterRepository characterRepository; + private final NpcRepository npcRepository; public CampaignStructuralContextBuilder( CampaignRepository campaignRepository, ArcRepository arcRepository, ChapterRepository chapterRepository, SceneRepository sceneRepository, - CharacterRepository characterRepository) { + CharacterRepository characterRepository, + NpcRepository npcRepository) { this.campaignRepository = campaignRepository; this.arcRepository = arcRepository; this.chapterRepository = chapterRepository; this.sceneRepository = sceneRepository; this.characterRepository = characterRepository; + this.npcRepository = npcRepository; } - /** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */ + /** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */ private static final int CHARACTER_SNIPPET_MAX_LEN = 160; /** @@ -79,11 +85,17 @@ public class CampaignStructuralContextBuilder { .map(this::toCharacterSummary) .collect(Collectors.toList()); + List npcs = npcRepository.findByCampaignId(campaignId).stream() + .sorted(Comparator.comparingInt(Npc::getOrder)) + .map(this::toNpcSummary) + .collect(Collectors.toList()); + return new CampaignStructuralContext( campaign.getName(), campaign.getDescription(), arcs, - characters); + characters, + npcs); } /** @@ -95,6 +107,11 @@ public class CampaignStructuralContextBuilder { return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent())); } + /** Symétrique à {@link #toCharacterSummary} pour les PNJ. */ + private NpcSummary toNpcSummary(Npc n) { + return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent())); + } + private static String extractSnippet(String markdown) { if (markdown == null || markdown.isBlank()) return ""; String firstLine = markdown.lines() 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 54256a5..7767225 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java @@ -3,10 +3,12 @@ package com.loremind.application.generationcontext; import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Character; +import com.loremind.domain.campaigncontext.Npc; 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.NpcRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.generationcontext.NarrativeEntityContext; import org.springframework.stereotype.Component; @@ -29,22 +31,25 @@ public class NarrativeEntityContextBuilder { private final ChapterRepository chapterRepository; private final SceneRepository sceneRepository; private final CharacterRepository characterRepository; + private final NpcRepository npcRepository; public NarrativeEntityContextBuilder( ArcRepository arcRepository, ChapterRepository chapterRepository, SceneRepository sceneRepository, - CharacterRepository characterRepository) { + CharacterRepository characterRepository, + NpcRepository npcRepository) { this.arcRepository = arcRepository; this.chapterRepository = chapterRepository; this.sceneRepository = sceneRepository; this.characterRepository = characterRepository; + this.npcRepository = npcRepository; } /** * Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext. * - * @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse) + * @param entityType "arc", "chapter", "scene", "character" ou "npc" (insensible à la casse) * @param entityId l'ID de l'entité * @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable */ @@ -55,6 +60,7 @@ public class NarrativeEntityContextBuilder { case "chapter" -> fromChapter(loadChapter(entityId)); case "scene" -> fromScene(loadScene(entityId)); case "character" -> fromCharacter(loadCharacter(entityId)); + case "npc" -> fromNpc(loadNpc(entityId)); default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType); }; } @@ -81,6 +87,11 @@ public class NarrativeEntityContextBuilder { .orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id)); } + private Npc loadNpc(String id) { + return npcRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("PNJ non trouvé: " + id)); + } + // --- Mapping entité → VO ------------------------------------------------ private NarrativeEntityContext fromArc(Arc a) { @@ -123,6 +134,12 @@ public class NarrativeEntityContextBuilder { return new NarrativeEntityContext("character", c.getName(), fields); } + private NarrativeEntityContext fromNpc(Npc n) { + Map fields = new LinkedHashMap<>(); + putField(fields, "fiche complète (markdown)", n.getMarkdownContent()); + return new NarrativeEntityContext("npc", n.getName(), fields); + } + /** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */ private static void putField(Map target, String key, String value) { target.put(key, value == null ? "" : value); diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java index e9e13b5..459a6f0 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java @@ -12,9 +12,10 @@ import java.time.LocalDateTime; * backstory, équipement). Évolution prévue vers un système templaté par * GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D). *

- * Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou - * dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une - * campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ). + * Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée + * (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents). + * Évolution prévue : système de templating partagé PJ/PNJ piloté par + * GameSystem pour adapter les blocs aux différents systèmes de JDR. */ @Data @Builder diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java b/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java new file mode 100644 index 0000000..9969ddc --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java @@ -0,0 +1,41 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Fiche de personnage non-joueur (PNJ) d'une campagne. + *

+ * MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé — + * un PNJ a vocation à porter à terme des invariants métier propres (faction, + * statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ) + * qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité + * PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern). + *

+ * Contenu markdown libre comme les PJ. Évolution prévue : templating partagé + * PJ/PNJ piloté par GameSystem. + *

+ * Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent + * gérés via le système Page/Template du LoreContext. + */ +@Data +@Builder +public class Npc { + + private String id; + private String name; + + /** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */ + private String markdownContent; + + /** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */ + private String campaignId; + + /** Ordre d'affichage dans la liste des PNJ de la campagne. */ + private int order; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file 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 new file mode 100644 index 0000000..6470f15 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NpcRepository.java @@ -0,0 +1,22 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.Npc; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des fiches de PNJ (campagne). + */ +public interface NpcRepository { + + Npc save(Npc npc); + + Optional findById(String id); + + List findByCampaignId(String campaignId); + + void deleteById(String id); + + boolean existsById(String id); +} 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 02e02fe..a3d2e18 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java @@ -22,12 +22,14 @@ import java.util.List; * Record Java : pur domaine, aucune dépendance technique. * * @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun. + * @param npcs Personnages non-joueurs (PNJ) de la campagne. Vide si aucun. */ public record CampaignStructuralContext( String campaignName, String campaignDescription, List arcs, - List characters) { + List characters, + List npcs) { /** * Résumé d'un PJ : nom + snippet court du markdown. @@ -39,6 +41,14 @@ public record CampaignStructuralContext( public record CharacterSummary(String name, String snippet) { } + /** + * Résumé d'un PNJ : symétrique à {@link CharacterSummary}. + * Snippet court extrait du markdown — la fiche complète est réservée + * à un usage focus (à venir, entity_type="npc"). + */ + public record NpcSummary(String name, String snippet) { + } + /** * Résumé d'un arc : nom + description courte + ses chapitres. * 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 b6eeb3e..14c13fb 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java @@ -5,6 +5,7 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummar import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint; import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatRequest; @@ -132,6 +133,12 @@ public class BrainChatPayloadBuilder { .map(this::characterSummaryToMap) .collect(Collectors.toList())); } + // Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload. + if (ctx.npcs() != null && !ctx.npcs().isEmpty()) { + map.put("npcs", ctx.npcs().stream() + .map(this::npcSummaryToMap) + .collect(Collectors.toList())); + } return map; } @@ -144,6 +151,15 @@ public class BrainChatPayloadBuilder { return map; } + private Map npcSummaryToMap(NpcSummary n) { + Map map = new LinkedHashMap<>(); + map.put("name", n.name()); + if (n.snippet() != null && !n.snippet().isBlank()) { + map.put("snippet", n.snippet()); + } + return map; + } + /** * Helper générique pour sérialiser les entités structurelles (Arc/Chapter/Scene) * avec name, description et illustration_count conditionnel. diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java new file mode 100644 index 0000000..c6a8d10 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java @@ -0,0 +1,55 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Entité JPA pour les fiches de PNJ d'une campagne. + * Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte). + */ +@Entity +@Table(name = "npcs") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NpcJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(name = "markdown_content", columnDefinition = "TEXT") + private String markdownContent; + + @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(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} 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 new file mode 100644 index 0000000..ab08d29 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NpcJpaRepository.java @@ -0,0 +1,13 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.NpcJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NpcJpaRepository extends JpaRepository { + + List findByCampaignIdOrderByOrderAsc(Long campaignId); +} 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 new file mode 100644 index 0000000..f72eb28 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java @@ -0,0 +1,75 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.campaigncontext.Npc; +import com.loremind.domain.campaigncontext.ports.NpcRepository; +import com.loremind.infrastructure.persistence.entity.NpcJpaEntity; +import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +public class PostgresNpcRepository implements NpcRepository { + + private final NpcJpaRepository jpaRepository; + + public PostgresNpcRepository(NpcJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Npc save(Npc npc) { + NpcJpaEntity entity = toJpaEntity(npc); + NpcJpaEntity saved = jpaRepository.save(entity); + return toDomainEntity(saved); + } + + @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 boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + private Npc toDomainEntity(NpcJpaEntity e) { + return Npc.builder() + .id(e.getId().toString()) + .name(e.getName()) + .markdownContent(e.getMarkdownContent()) + .campaignId(e.getCampaignId().toString()) + .order(e.getOrder()) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } + + private NpcJpaEntity toJpaEntity(Npc n) { + Long id = n.getId() != null ? Long.parseLong(n.getId()) : null; + return NpcJpaEntity.builder() + .id(id) + .name(n.getName()) + .markdownContent(n.getMarkdownContent()) + .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/web/controller/NpcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java new file mode 100644 index 0000000..4e7d6f3 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java @@ -0,0 +1,62 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.NpcService; +import com.loremind.domain.campaigncontext.Npc; +import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO; +import com.loremind.infrastructure.web.mapper.NpcMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/npcs") +public class NpcController { + + private final NpcService npcService; + private final NpcMapper npcMapper; + + public NpcController(NpcService npcService, NpcMapper npcMapper) { + this.npcService = npcService; + this.npcMapper = npcMapper; + } + + @PostMapping + public ResponseEntity createNpc(@RequestBody NpcDTO dto) { + Npc created = npcService.createNpc( + new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null) + ); + return ResponseEntity.ok(npcMapper.toDTO(created)); + } + + @GetMapping("/{id}") + public ResponseEntity getNpcById(@PathVariable String id) { + return npcService.getNpcById(id) + .map(n -> ResponseEntity.ok(npcMapper.toDTO(n))) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/campaign/{campaignId}") + public ResponseEntity> getNpcsByCampaign(@PathVariable String campaignId) { + List dtos = npcService.getNpcsByCampaignId(campaignId).stream() + .map(npcMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @PutMapping("/{id}") + public ResponseEntity updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) { + Npc updated = npcService.updateNpc( + id, + new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder()) + ); + return ResponseEntity.ok(npcMapper.toDTO(updated)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteNpc(@PathVariable String id) { + npcService.deleteNpc(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java new file mode 100644 index 0000000..fa5e425 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java @@ -0,0 +1,16 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; + +/** + * DTO pour les fiches de PNJ d'une campagne. + */ +@Data +public class NpcDTO { + + private String id; + private String name; + private String markdownContent; + private String campaignId; + private int order; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java new file mode 100644 index 0000000..685c221 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java @@ -0,0 +1,31 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.campaigncontext.Npc; +import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO; +import org.springframework.stereotype.Component; + +@Component +public class NpcMapper { + + public NpcDTO toDTO(Npc n) { + if (n == null) return null; + NpcDTO dto = new NpcDTO(); + dto.setId(n.getId()); + dto.setName(n.getName()); + dto.setMarkdownContent(n.getMarkdownContent()); + dto.setCampaignId(n.getCampaignId()); + dto.setOrder(n.getOrder()); + return dto; + } + + public Npc toDomain(NpcDTO dto) { + if (dto == null) return null; + return Npc.builder() + .id(dto.getId()) + .name(dto.getName()) + .markdownContent(dto.getMarkdownContent()) + .campaignId(dto.getCampaignId()) + .order(dto.getOrder()) + .build(); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java new file mode 100644 index 0000000..f95311f --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java @@ -0,0 +1,159 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Npc; +import com.loremind.domain.campaigncontext.ports.NpcRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour NpcService. + * Couvre la création (avec auto-calcul de l'order), la lecture, la mise à jour + * (incl. cas non trouvé), la suppression, et le calcul d'order. + */ +@ExtendWith(MockitoExtension.class) +public class NpcServiceTest { + + @Mock + private NpcRepository npcRepository; + + @InjectMocks + private NpcService npcService; + + private Npc testNpc; + + @BeforeEach + void setUp() { + testNpc = Npc.builder() + .id("npc-1") + .name("Borin le forgeron") + .markdownContent("# Borin\nForgeron nain") + .campaignId("camp-1") + .order(1) + .build(); + } + + @Test + void testCreateNpc_WithExplicitOrder() { + when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); + + Npc result = npcService.createNpc( + new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5)); + + assertNotNull(result); + ArgumentCaptor captor = ArgumentCaptor.forClass(Npc.class); + verify(npcRepository).save(captor.capture()); + assertEquals(5, captor.getValue().getOrder()); + } + + @Test + void testCreateNpc_AutoComputesNextOrder_WhenNullProvided() { + // Existant : 2 PNJ avec orders 0 et 3 → next = 4 + Npc a = Npc.builder().id("a").campaignId("camp-1").order(0).build(); + Npc b = Npc.builder().id("b").campaignId("camp-1").order(3).build(); + when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b)); + when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); + + npcService.createNpc(new NpcService.NpcData("Nouveau", null, "camp-1", null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Npc.class); + verify(npcRepository).save(captor.capture()); + assertEquals(4, captor.getValue().getOrder()); + } + + @Test + void testCreateNpc_FirstNpcGetsOrderZero() { + when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); + when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); + + npcService.createNpc(new NpcService.NpcData("Premier", null, "camp-1", null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Npc.class); + verify(npcRepository).save(captor.capture()); + assertEquals(0, captor.getValue().getOrder()); + } + + @Test + void testGetNpcById_Found() { + when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc)); + + Optional result = npcService.getNpcById("npc-1"); + + assertTrue(result.isPresent()); + assertEquals("Borin le forgeron", result.get().getName()); + } + + @Test + void testGetNpcById_NotFound() { + when(npcRepository.findById("missing")).thenReturn(Optional.empty()); + + Optional result = npcService.getNpcById("missing"); + + assertFalse(result.isPresent()); + } + + @Test + void testGetNpcsByCampaignId_DelegatesToRepository() { + Npc a = Npc.builder().id("a").campaignId("camp-1").order(1).build(); + Npc b = Npc.builder().id("b").campaignId("camp-1").order(2).build(); + when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b)); + + List result = npcService.getNpcsByCampaignId("camp-1"); + + assertEquals(2, result.size()); + verify(npcRepository).findByCampaignId("camp-1"); + } + + @Test + void testUpdateNpc_Success() { + when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc)); + when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0)); + + Npc result = npcService.updateNpc("npc-1", + new NpcService.NpcData("Borin renommé", "# v2", "camp-1", 7)); + + assertEquals("Borin renommé", result.getName()); + assertEquals("# v2", result.getMarkdownContent()); + assertEquals(7, result.getOrder()); + } + + @Test + void testUpdateNpc_OrderNullPreservesExistingOrder() { + when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc)); + when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0)); + + Npc result = npcService.updateNpc("npc-1", + new NpcService.NpcData("Borin", "# txt", "camp-1", null)); + + // testNpc avait order=1 → préservé + assertEquals(1, result.getOrder()); + } + + @Test + void testUpdateNpc_NotFoundThrows() { + when(npcRepository.findById("missing")).thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> npcService.updateNpc("missing", + new NpcService.NpcData("x", null, "camp-1", null))); + assertTrue(ex.getMessage().contains("missing")); + verify(npcRepository, never()).save(any()); + } + + @Test + void testDeleteNpc_DelegatesToRepository() { + npcService.deleteNpc("npc-1"); + verify(npcRepository).deleteById("npc-1"); + } +} diff --git a/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java index 6114243..8e38cd0 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java @@ -3,12 +3,15 @@ package com.loremind.application.generationcontext; import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Character; +import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.SceneBranch; 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.NpcRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.generationcontext.CampaignStructuralContext; import org.junit.jupiter.api.BeforeEach; @@ -43,6 +46,8 @@ public class CampaignStructuralContextBuilderTest { private SceneRepository sceneRepository; @Mock private CharacterRepository characterRepository; + @Mock + private NpcRepository npcRepository; @InjectMocks private CampaignStructuralContextBuilder builder; @@ -144,6 +149,66 @@ public class CampaignStructuralContextBuilderTest { assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName()); } + @Test + void testBuild_ProjectsCharactersAndNpcsWithSnippets() { + Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1) + .name("Aragorn") + .markdownContent("# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.") + .build(); + Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2) + .name("Legolas") + .markdownContent(null) // pas de snippet → string vide + .build(); + Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2) + .name("Borin le forgeron") + .markdownContent("# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.") + .build(); + Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1) + .name("Dame Elara") + .markdownContent("") + .build(); + + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); + when(characterRepository.findByCampaignId("camp-1")).thenReturn(List.of(pj2, pj1)); + when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(npc1, npc2)); + + CampaignStructuralContext ctx = builder.build("camp-1"); + + // PJ triés par order croissant + assertEquals(2, ctx.characters().size()); + assertEquals("Aragorn", ctx.characters().get(0).name()); + assertEquals("Rôdeur du Nord, héritier d'Isildur.", ctx.characters().get(0).snippet()); + assertEquals("Legolas", ctx.characters().get(1).name()); + assertEquals("", ctx.characters().get(1).snippet()); + + // PNJ triés par order croissant : Elara (1) avant Borin (2) + assertEquals(2, ctx.npcs().size()); + assertEquals("Dame Elara", ctx.npcs().get(0).name()); + assertEquals("", ctx.npcs().get(0).snippet()); + assertEquals("Borin le forgeron", ctx.npcs().get(1).name()); + assertEquals("Nain barbu au regard perçant, ancien clan Feuillefer.", + ctx.npcs().get(1).snippet()); + } + + @Test + void testBuild_TruncatesLongSnippet() { + // Snippet > 160 chars : doit être tronqué à 159 + "…" + String longLine = "x".repeat(200); + Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1) + .name("Verbeux").markdownContent(longLine).build(); + + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); + when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(longNpc)); + + CampaignStructuralContext ctx = builder.build("camp-1"); + + String snippet = ctx.npcs().get(0).snippet(); + assertEquals(160, snippet.length()); + assertTrue(snippet.endsWith("…")); + } + @Test void testBuild_CountsIllustrationsNullSafe() { Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1) diff --git a/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java index 82ab6e7..4f71989 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java @@ -2,9 +2,13 @@ package com.loremind.application.generationcontext; import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Character; +import com.loremind.domain.campaigncontext.Npc; 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.NpcRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.generationcontext.NarrativeEntityContext; import org.junit.jupiter.api.Test; @@ -30,6 +34,8 @@ public class NarrativeEntityContextBuilderTest { @Mock private ArcRepository arcRepository; @Mock private ChapterRepository chapterRepository; @Mock private SceneRepository sceneRepository; + @Mock private CharacterRepository characterRepository; + @Mock private NpcRepository npcRepository; @InjectMocks private NarrativeEntityContextBuilder builder; @@ -107,11 +113,59 @@ public class NarrativeEntityContextBuilderTest { assertEquals("arc", ctx.entityType()); } + @Test + void testBuild_Character_MarkdownProjected() { + Character c = Character.builder() + .id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur") + .build(); + when(characterRepository.findById("c-1")).thenReturn(Optional.of(c)); + + NarrativeEntityContext ctx = builder.build("character", "c-1"); + + assertEquals("character", ctx.entityType()); + assertEquals("Aragorn", ctx.title()); + assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)")); + } + + @Test + void testBuild_Npc_MarkdownProjected() { + Npc n = Npc.builder() + .id("n-1").name("Borin le forgeron") + .markdownContent("# Borin\n**Faction :** Clan Feuillefer") + .build(); + when(npcRepository.findById("n-1")).thenReturn(Optional.of(n)); + + NarrativeEntityContext ctx = builder.build("npc", "n-1"); + + assertEquals("npc", ctx.entityType()); + assertEquals("Borin le forgeron", ctx.title()); + assertEquals("# Borin\n**Faction :** Clan Feuillefer", + ctx.fields().get("fiche complète (markdown)")); + } + + @Test + void testBuild_Npc_NormalizesCase() { + Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build(); + when(npcRepository.findById("n-1")).thenReturn(Optional.of(n)); + + NarrativeEntityContext ctx = builder.build(" NPC ", "n-1"); + assertEquals("npc", ctx.entityType()); + } + + @Test + void testBuild_NpcNotFoundThrows() { + when(npcRepository.findById("missing")).thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> builder.build("npc", "missing")); + assertTrue(ex.getMessage().contains("missing")); + } + @Test void testBuild_UnknownTypeThrows() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> builder.build("npc", "id")); - assertTrue(ex.getMessage().contains("npc")); + () -> builder.build("alien", "id")); + assertTrue(ex.getMessage().contains("alien")); } @Test diff --git a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java index 42859e4..6d797e0 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java @@ -55,7 +55,7 @@ public class StreamChatForCampaignUseCaseTest { @SuppressWarnings("unchecked") @BeforeEach void setUp() { - campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of()); + campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of(), List.of()); messages = List.of(); onUsage = mock(Consumer.class); onToken = mock(Consumer.class); 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 72ff6a2..aeb42b6 100644 --- a/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java +++ b/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java @@ -43,6 +43,7 @@ class CampaignStructuralContextTest { "Les Ombres", "Une campagne dark fantasy", List.of(arc), + List.of(), List.of()); assertEquals("Les Ombres", ctx.campaignName()); diff --git a/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java b/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java index b677712..eae67f6 100644 --- a/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java +++ b/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java @@ -56,7 +56,7 @@ class ChatRequestTest { ChatRequest request = ChatRequest.builder() .messages(sampleMessages) .campaignContext(new CampaignStructuralContext( - "Les Ombres", "...", List.of(), List.of())) + "Les Ombres", "...", List.of(), List.of(), List.of())) .narrativeEntity(new NarrativeEntityContext( "scene", "L'auberge", Map.of("location", "Taverne"))) .build(); 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 e100c3d..17a9ac8 100644 --- a/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java @@ -167,7 +167,7 @@ class BrainChatPayloadBuilderTest { ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene)); ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter)); CampaignStructuralContext camp = new CampaignStructuralContext( - "Les Ombres", "dark fantasy", List.of(arc), List.of()); + "Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of()); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); Map payload = builder.build(req); @@ -200,7 +200,7 @@ class BrainChatPayloadBuilderTest { void build_arcSummary_omitsIllustrationCount_whenZero() { ArcSummary arc = new ArcSummary("A", "", 0, List.of()); CampaignStructuralContext camp = new CampaignStructuralContext( - "X", "", List.of(arc), List.of()); + "X", "", List.of(arc), List.of(), List.of()); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); Map payload = builder.build(req); @@ -217,7 +217,7 @@ class BrainChatPayloadBuilderTest { ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); CampaignStructuralContext camp = new CampaignStructuralContext( - "X", "", List.of(arc), List.of()); + "X", "", List.of(arc), List.of(), List.of()); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); Map payload = builder.build(req); @@ -236,7 +236,7 @@ class BrainChatPayloadBuilderTest { ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); CampaignStructuralContext camp = new CampaignStructuralContext( - "X", "", List.of(arc), List.of()); + "X", "", List.of(arc), List.of(), List.of()); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); Map payload = builder.build(req); @@ -269,7 +269,7 @@ class BrainChatPayloadBuilderTest { @Test void build_campaignScenario_includesBothContextsAndEntity() { CampaignStructuralContext camp = new CampaignStructuralContext( - "X", "", List.of(), List.of()); + "X", "", List.of(), List.of(), List.of()); NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of()); ChatRequest req = ChatRequest.builder() .messages(sampleMessages) diff --git a/installers/README.md b/installers/README.md deleted file mode 100644 index 687a22b..0000000 --- a/installers/README.md +++ /dev/null @@ -1,207 +0,0 @@ -# LoreMindMJ — Installation rapide - -Ces scripts installent Docker (si nécessaire), génèrent un `.env` sécurisé -et lancent la stack. Aucune configuration manuelle requise. - -## Windows 10 / 11 - -**Procédure recommandée :** - -1. Téléchargez les trois fichiers suivants dans un même dossier - (par ex. `Téléchargements\LoreMind\`) : - - [`install.bat`](install.bat) — lanceur - - [`install.ps1`](install.ps1) — script principal - - [`secure-host-ollama.ps1`](secure-host-ollama.ps1) — *uniquement si vous avez déjà Ollama sur votre PC* -2. **Clic-droit** sur `install.bat` → **Exécuter en tant qu'administrateur**. -3. Acceptez le prompt UAC. - -Le script : -1. Vérifie / installe **WSL2** (un reboot peut être nécessaire — relancer le script après). -2. Vérifie / installe **Docker Desktop** via `winget`. -3. Vous demande quelques choix (admin, fournisseur LLM, mode Ollama, mises à jour auto). -4. Génère `%LOCALAPPDATA%\LoreMind\.env` avec mots de passe aléatoires. -5. Lance la stack et ouvre `http://localhost:8081`. - -Le `install.bat` sert juste à lancer `install.ps1` proprement (avec UAC + ExecutionPolicy -adaptée à la session, sans modifier les paramètres système). Il est purement -déclaratif et auditable en quelques lignes. - -## Linux (Debian / Ubuntu / Fedora / Arch) - -```bash -curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash -``` - -Le script : -1. Installe **Docker** via le script officiel `get.docker.com` si absent. -2. Ajoute l'utilisateur courant au groupe `docker` (relogin nécessaire la 1ʳᵉ fois). -3. Installe dans `~/.local/share/loremind`. -4. Lance la stack et ouvre `http://localhost:8081`. - -## Mode Ollama (moteur LLM local) - -Pendant l'installation, l'installeur pose deux questions successives pour -déterminer comment LoreMind utilisera Ollama : - -### 1. *« Avez-vous déjà Ollama installé sur cette machine ? »* - -#### Réponse : **Oui** → mode **hôte sécurisé** - -L'installeur appelle automatiquement le helper `secure-host-ollama.{sh,ps1}` -qui configure votre Ollama existant pour qu'il soit joignable par le conteneur -Docker LoreMind **sans être exposé sur le réseau local ni Internet**. - -- **Linux** : Ollama écoute sur l'IP de la passerelle Docker (`172.17.0.1` - par défaut). Cette IP n'est jamais routée hors de la machine. Override - systemd écrit dans `/etc/systemd/system/ollama.service.d/loremind-host.conf`. -- **Windows** : Ollama écoute sur `0.0.0.0` (techniquement nécessaire avec - Docker Desktop) mais le pare-feu Windows est configuré pour ne **laisser - passer que** le loopback et les sous-réseaux Docker Desktop. Règles - ajoutées préfixées `LoreMind-Ollama-*`. - -L'URL configurée dans `.env` est `OLLAMA_BASE_URL=http://host.docker.internal:11434`. - -#### Réponse : **Non** → l'installeur pose la question 2. - -### 2. *« Voulez-vous installer Ollama via Docker maintenant ? »* - -#### Réponse : **Oui (défaut)** → mode **embarqué** - -Un service `ollama` est ajouté à la stack via le profile Docker `local-ollama`. -Ollama tourne dans un conteneur dédié, sur le réseau interne Docker, **jamais -exposé au LAN ni à Internet**. Les modèles sont stockés dans le volume -Docker `ollama-data` (persistants entre redémarrages et mises à jour). - -- URL : `OLLAMA_BASE_URL=http://ollama:11434` (DNS interne Docker). -- Aucune configuration réseau ou pare-feu requise. -- Support GPU NVIDIA automatique si disponible. - -Pour télécharger un modèle : - -```bash -docker exec -it loremind-ollama ollama pull gemma3:27b -docker exec -it loremind-ollama ollama list -``` - -#### Réponse : **Non** → mode **différé** - -Aucune configuration Ollama n'est appliquée. L'installeur termine sans -Ollama. Vous configurez Ollama plus tard via la page **Paramètres** de LoreMind -en y indiquant l'URL de votre serveur Ollama. - -### Lancer le helper de sécurisation manuellement - -Si vous avez choisi le mode différé puis installé Ollama plus tard sur votre -poste, ou si vous voulez basculer du mode embarqué vers le mode hôte : - -**Linux :** -```bash -bash secure-host-ollama.sh -# Puis dans .env du dossier d'installation : -# OLLAMA_BASE_URL=http://host.docker.internal:11434 -# Et : docker compose up -d -``` - -**Windows (PowerShell admin) :** -```powershell -.\secure-host-ollama.ps1 -# Puis editez .env (dans %LOCALAPPDATA%\LoreMind\) : -# OLLAMA_BASE_URL=http://host.docker.internal:11434 -# Et : docker compose up -d -``` - -Les helpers sont **réexécutables sans risque** : ils suppriment leurs -anciennes règles avant de les recréer. Utile par exemple si vous avez -réinitialisé Docker Desktop et que les sous-réseaux ont changé. - -### Annuler la configuration de sécurisation - -**Linux :** -```bash -sudo rm /etc/systemd/system/ollama.service.d/loremind-host.conf -sudo systemctl daemon-reload && sudo systemctl restart ollama -``` - -**Windows (PowerShell admin) :** -```powershell -Get-NetFirewallRule -DisplayName "LoreMind-Ollama-*" | Remove-NetFirewallRule -[Environment]::SetEnvironmentVariable("OLLAMA_HOST", $null, "User") -``` - -## Variables disponibles - -| Variable | Défaut | Effet | -|-------------------|---------------------------------|----------------------------------------| -| `WEB_PORT` | `8081` | Port HTTP de l'UI | -| `INSTALL_DIR` | `~/.local/share/loremind` (Lin) | Dossier d'installation | -| `NON_INTERACTIVE` | `0` | `1` = aucune question, valeurs par défaut | - -Exemple Linux non-interactif sur port 9000 : - -```bash -WEB_PORT=9000 NON_INTERACTIVE=1 bash install.sh -``` - -## Mises à jour automatiques (Watchtower) - -Si vous avez répondu **oui** à la question "Activer les mises à jour auto", -un container [Watchtower](https://containrrr.dev/watchtower/) est lancé en -parallèle. Il vérifie chaque nuit à 4h les nouvelles versions de -`core`, `brain` et `web` sur le registry, télécharge et redémarre les -conteneurs concernés. **Postgres et MinIO sont volontairement exclus** -(données persistantes — montée de version à valider manuellement). - -### Activer / désactiver après coup - -Éditer `.env` dans le dossier d'installation : - -```env -COMPOSE_PROFILES=autoupdate # active -COMPOSE_PROFILES= # desactive -``` - -Puis : - -```bash -docker compose up -d # applique le changement -docker compose stop watchtower # si on vient de le desactiver -``` - -### Changer l'horaire - -`WATCHTOWER_SCHEDULE` dans `.env` accepte la syntaxe -[cron 6 champs](https://pkg.go.dev/github.com/robfig/cron) (sec min h jour mois j-sem). -Exemples : `0 0 4 * * *` (4h du matin, défaut), `0 30 3 * * 0` (dimanche 3h30). - -### Mode "notification seulement" (sans auto-apply) - -Si vous préférez être notifié *sans* que les conteneurs redémarrent -automatiquement la nuit, éditez `.env` : - -```env -WATCHTOWER_MONITOR_ONLY=true -``` - -Puis `docker compose up -d watchtower`. Watchtower continuera à vérifier -le registry chaque nuit, le badge **MAJ** apparaîtra dans la sidebar de -l'UI, et un bouton **Mettre à jour maintenant** sera disponible dans -*Paramètres → Mises à jour*. - -### Mise à jour manuelle (à tout moment) - -Depuis l'interface : *Paramètres → Mises à jour → Mettre à jour maintenant*. - -Ou en CLI : - -```bash -docker compose pull && docker compose up -d -``` - -## Désinstallation - -```bash -cd -docker compose down -v # -v supprime aussi les volumes (données effacées !) -``` - -Puis supprimer le dossier d'installation. diff --git a/web/e2e/fixtures/api.ts b/web/e2e/fixtures/api.ts index ac5f0ff..ea3c320 100644 --- a/web/e2e/fixtures/api.ts +++ b/web/e2e/fixtures/api.ts @@ -301,6 +301,46 @@ export async function getPageById( return res.json(); } +export interface SeededNpc { + id: string; + name: string; +} + +export async function seedNpc( + request: APIRequestContext, + opts: { campaignId: string; name?: string; markdownContent?: string | null }, +): Promise { + const name = opts.name ?? `E2E NPC ${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const res = await request.post('/api/npcs', { + data: { + campaignId: opts.campaignId, + name, + markdownContent: opts.markdownContent ?? null, + }, + }); + expect(res.ok(), `POST /api/npcs -> ${res.status()}`).toBeTruthy(); + const n = await res.json(); + return { id: n.id, name }; +} + +export async function getNpcById( + request: APIRequestContext, + npcId: string, +): Promise<{ id: string; name: string; markdownContent: string | null; campaignId: string; order: number }> { + const res = await request.get(`/api/npcs/${npcId}`); + expect(res.ok(), `GET /api/npcs/${npcId} -> ${res.status()}`).toBeTruthy(); + return res.json(); +} + +export async function getNpcsByCampaign( + request: APIRequestContext, + campaignId: string, +): Promise> { + const res = await request.get(`/api/npcs/campaign/${campaignId}`); + expect(res.ok(), `GET /api/npcs/campaign -> ${res.status()}`).toBeTruthy(); + return res.json(); +} + export async function getTemplateById( request: APIRequestContext, templateId: string, diff --git a/web/e2e/tests/campaign/npc-create.spec.ts b/web/e2e/tests/campaign/npc-create.spec.ts new file mode 100644 index 0000000..b29cf1d --- /dev/null +++ b/web/e2e/tests/campaign/npc-create.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; +import { + seedCampaign, + deleteCampaign, + getNpcsByCampaign, + type SeededCampaign, +} from '../../fixtures/api'; + +test.describe('NPC creation', () => { + let campaign: SeededCampaign; + + test.beforeEach(async ({ request }) => { + campaign = await seedCampaign(request); + }); + + test.afterEach(async ({ request }) => { + if (campaign?.id) await deleteCampaign(request, campaign.id); + }); + + test('creates an NPC and redirects back to the campaign', async ({ page, request }) => { + const npcName = `Borin le forgeron ${Date.now()}`; + const markdown = '# Borin\n\n**Faction :** Clan Feuillefer\n\nNain barbu au regard perçant.'; + + await page.goto(`/campaigns/${campaign.id}/npcs/create`); + await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible(); + + await page.getByLabel(/Nom du PNJ/i).fill(npcName); + await page.getByLabel(/Fiche \(markdown\)/i).fill(markdown); + + await page.getByRole('button', { name: /^Créer$/i }).click(); + + // Retour à la page campagne après création + await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`)); + + // Persistance vérifiée via API + const npcs = await getNpcsByCampaign(request, campaign.id); + const created = npcs.find((n) => n.name === npcName); + expect(created).toBeDefined(); + }); + + test('submit is disabled when name is empty', async ({ page }) => { + await page.goto(`/campaigns/${campaign.id}/npcs/create`); + + const submit = page.getByRole('button', { name: /^Créer$/i }); + await expect(submit).toBeDisabled(); + + await page.getByLabel(/Nom du PNJ/i).fill('Elara'); + await expect(submit).toBeEnabled(); + + await page.getByLabel(/Nom du PNJ/i).fill(' '); + await expect(submit).toBeDisabled(); + }); + + test('NPC appears in the sidebar PNJ branch', async ({ page, request }) => { + const npcName = `Sidebar test ${Date.now()}`; + + await page.goto(`/campaigns/${campaign.id}/npcs/create`); + await page.getByLabel(/Nom du PNJ/i).fill(npcName); + await page.getByRole('button', { name: /^Créer$/i }).click(); + + await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`)); + + // Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ. + // On clique sur le nœud PNJ pour le déplier au cas où il serait fermé, + // puis on vérifie que le PNJ est listé. + const pnjNode = page.getByRole('button', { name: /^PNJ\b/ }).or( + page.locator('.tree-item', { hasText: 'PNJ' }).first(), + ); + await expect(pnjNode.first()).toBeVisible(); + + // Vérification fallback via API : la liste contient bien le PNJ créé. + const npcs = await getNpcsByCampaign(request, campaign.id); + expect(npcs.map((n) => n.name)).toContain(npcName); + }); +}); diff --git a/web/e2e/tests/campaign/npc-edit.spec.ts b/web/e2e/tests/campaign/npc-edit.spec.ts new file mode 100644 index 0000000..2b32ae9 --- /dev/null +++ b/web/e2e/tests/campaign/npc-edit.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from '@playwright/test'; +import { + seedCampaign, + seedNpc, + deleteCampaign, + getNpcById, + type SeededCampaign, + type SeededNpc, +} from '../../fixtures/api'; + +test.describe('NPC edit', () => { + let campaign: SeededCampaign; + let npc: SeededNpc; + + test.beforeEach(async ({ request }) => { + campaign = await seedCampaign(request); + npc = await seedNpc(request, { + campaignId: campaign.id, + markdownContent: '# Initial\n\nFiche de départ.', + }); + }); + + test.afterEach(async ({ request }) => { + if (campaign?.id) await deleteCampaign(request, campaign.id); + }); + + test('edits name + markdown content and persists via API', async ({ page, request }) => { + const newName = `${npc.name} (renommé)`; + const newMarkdown = '# Borin réécrit\n\n**Statut :** Disparu\n\nDes traces dans la neige...'; + + await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`); + + await expect(page.getByRole('heading', { name: /Éditer le PNJ/i })).toBeVisible(); + await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name); + + await page.getByLabel(/Nom du PNJ/i).fill(newName); + await page.getByLabel(/Fiche \(markdown\)/i).fill(newMarkdown); + + await page.getByRole('button', { name: /^Enregistrer$/i }).click(); + + // Retour à la campagne après save + await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`)); + + const persisted = await getNpcById(request, npc.id); + expect(persisted.name).toBe(newName); + expect(persisted.markdownContent).toBe(newMarkdown); + }); + + test('save button is disabled when name is cleared', async ({ page }) => { + await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`); + + const nameField = page.getByLabel(/Nom du PNJ/i); + const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i }); + + await expect(saveBtn).toBeEnabled(); + await nameField.fill(''); + await expect(saveBtn).toBeDisabled(); + await nameField.fill('OK'); + await expect(saveBtn).toBeEnabled(); + }); + + test('Assistant IA button is visible in edit mode', async ({ page }) => { + // Vérifie l'intégration drawer chat IA — symétrique aux PJ. + // Note : le drawer lui-même nécessite le Brain Python en route, donc + // on ne teste que la présence du bouton trigger. + await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`); + await expect(page.getByRole('button', { name: /Assistant IA/i })).toBeVisible(); + }); +}); diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 9725732..8d5b61c 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -15,19 +15,21 @@ export const routes: Routes = [ { path: 'lore/:loreId/pages/:pageId', loadComponent: () => import('./lore/page-view/page-view.component').then(m => m.PageViewComponent) }, { path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) }, { path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) }, - { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, - { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, - { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, - { path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, - { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, - { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, - { path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) }, - { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) }, - { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) }, - { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) }, - { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) }, - { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) }, - { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) }, + { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, + { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, + { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, + { 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/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, + { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, + { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, + { path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) }, + { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) }, + { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) }, + { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) }, + { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) }, + { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) }, + { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) }, { path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) }, { path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, { path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, diff --git a/web/src/app/campaigns/arc-create/arc-create.component.html b/web/src/app/campaigns/arc/arc-create/arc-create.component.html similarity index 100% rename from web/src/app/campaigns/arc-create/arc-create.component.html rename to web/src/app/campaigns/arc/arc-create/arc-create.component.html diff --git a/web/src/app/campaigns/arc-create/arc-create.component.scss b/web/src/app/campaigns/arc/arc-create/arc-create.component.scss similarity index 100% rename from web/src/app/campaigns/arc-create/arc-create.component.scss rename to web/src/app/campaigns/arc/arc-create/arc-create.component.scss diff --git a/web/src/app/campaigns/arc-create/arc-create.component.ts b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts similarity index 82% rename from web/src/app/campaigns/arc-create/arc-create.component.ts rename to web/src/app/campaigns/arc/arc-create/arc-create.component.ts index 065750b..648240e 100644 --- a/web/src/app/campaigns/arc-create/arc-create.component.ts +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts @@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule, BookOpen } from 'lucide-angular'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { Campaign } from '../../services/campaign.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; -import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component'; -import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { Campaign } from '../../../services/campaign.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; /** * Écran de création d'un nouvel Arc narratif (contexte Campagne). @@ -39,6 +40,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private layoutService: LayoutService ) { this.form = this.fb.group({ @@ -56,7 +58,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) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).subscribe(({ campaign, allCampaigns, treeData }) => { this.existingArcCount = treeData.arcs.length; diff --git a/web/src/app/campaigns/arc-edit/arc-edit.component.html b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html similarity index 100% rename from web/src/app/campaigns/arc-edit/arc-edit.component.html rename to web/src/app/campaigns/arc/arc-edit/arc-edit.component.html diff --git a/web/src/app/campaigns/arc-edit/arc-edit.component.scss b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss similarity index 100% rename from web/src/app/campaigns/arc-edit/arc-edit.component.scss rename to web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss diff --git a/web/src/app/campaigns/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts similarity index 85% rename from web/src/app/campaigns/arc-edit/arc-edit.component.ts rename to web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index 1d04b08..569423a 100644 --- a/web/src/app/campaigns/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { PageService } from '../../services/page.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { PageTitleService } from '../../services/page-title.service'; -import { Campaign, Arc } from '../../services/campaign.model'; -import { Page } from '../../services/page.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; -import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component'; -import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component'; -import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component'; -import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component'; -import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { PageService } from '../../../services/page.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Campaign, Arc } from '../../../services/campaign.model'; +import { Page } from '../../../services/page.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component'; +import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; +import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; +import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; /** * Écran de détail/modification d'un Arc. @@ -74,6 +75,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService @@ -111,7 +113,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) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/arc-view/arc-view.component.html b/web/src/app/campaigns/arc/arc-view/arc-view.component.html similarity index 100% rename from web/src/app/campaigns/arc-view/arc-view.component.html rename to web/src/app/campaigns/arc/arc-view/arc-view.component.html diff --git a/web/src/app/campaigns/arc-view/arc-view.component.scss b/web/src/app/campaigns/arc/arc-view/arc-view.component.scss similarity index 100% rename from web/src/app/campaigns/arc-view/arc-view.component.scss rename to web/src/app/campaigns/arc/arc-view/arc-view.component.scss diff --git a/web/src/app/campaigns/arc-view/arc-view.component.ts b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts similarity index 84% rename from web/src/app/campaigns/arc-view/arc-view.component.ts rename to web/src/app/campaigns/arc/arc-view/arc-view.component.ts index cda5b31..8947c3e 100644 --- a/web/src/app/campaigns/arc-view/arc-view.component.ts +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts @@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular'; -import { resolveCampaignIcon } from '../campaign-icons'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { PageService } from '../../services/page.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { PageTitleService } from '../../services/page-title.service'; -import { Campaign, Arc } from '../../services/campaign.model'; -import { Page } from '../../services/page.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; -import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component'; +import { resolveCampaignIcon } from '../../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { PageService } from '../../../services/page.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Campaign, Arc } from '../../../services/campaign.model'; +import { Page } from '../../../services/page.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; /** * Écran de consultation d'un Arc narratif (lecture seule). @@ -46,6 +47,7 @@ export class ArcViewComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService @@ -68,7 +70,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) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).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 b0a94ba..5d7fc90 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -2,9 +2,11 @@ import { Observable, forkJoin, of } from 'rxjs'; import { switchMap, map } from 'rxjs/operators'; import { CampaignService } from '../services/campaign.service'; import { CharacterService } from '../services/character.service'; +import { NpcService } from '../services/npc.service'; import { TreeItem } from '../services/layout.service'; import { Arc, Chapter, Scene } from '../services/campaign.model'; import { Character } from '../services/character.model'; +import { Npc } from '../services/npc.model'; /** * Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes) @@ -19,20 +21,23 @@ export interface CampaignTreeData { chaptersByArc: Record; scenesByChapter: Record; characters: Character[]; + npcs: Npc[]; } export function loadCampaignTreeData( service: CampaignService, campaignId: string, - characterService: CharacterService + characterService: CharacterService, + npcService: NpcService ): Observable { return forkJoin({ arcs: service.getArcs(campaignId), - characters: characterService.getByCampaign(campaignId) + characters: characterService.getByCampaign(campaignId), + npcs: npcService.getByCampaign(campaignId) }).pipe( - switchMap(({ arcs, characters }) => { + switchMap(({ arcs, characters, npcs }) => { if (arcs.length === 0) { - return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters }); + return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs }); } const chapterCalls = arcs.map(a => service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters }))) @@ -47,7 +52,7 @@ export function loadCampaignTreeData( }); if (allChapters.length === 0) { - return of({ arcs, chaptersByArc, scenesByChapter: {}, characters }); + return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs }); } const sceneCalls = allChapters.map(c => service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes }))) @@ -56,7 +61,7 @@ export function loadCampaignTreeData( map(sceneResults => { const scenesByChapter: Record = {}; sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; }); - return { arcs, chaptersByArc, scenesByChapter, characters }; + return { arcs, chaptersByArc, scenesByChapter, characters, npcs }; }) ); }) @@ -83,13 +88,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T const charactersNode: TreeItem = { id: 'characters-root', - label: 'Personnages', + label: 'PJ', iconKey: 'users', children: characterItems, meta: characterItems.length ? String(characterItems.length) : undefined, sectionHeaderBefore: 'Personnages', - // Note : si pas d'arcs, le filet au-dessus de "Personnages" est masqué par CSS - // (:first-child), ce qui est voulu — on ne veut pas de ligne seule en haut. + // Note : le section header "Personnages" est porté par le premier nœud (PJ). + // Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar. createActions: [{ id: 'new-character', label: 'Nouveau PJ', @@ -98,6 +103,28 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T }] }; + const sortedNpcs = [...data.npcs].sort(byName); + const npcItems: TreeItem[] = sortedNpcs.map(n => ({ + id: `npc-${n.id}`, + label: n.name, + route: `/campaigns/${campaignId}/npcs/${n.id}/edit` + })); + + const npcsNode: TreeItem = { + id: 'npcs-root', + label: 'PNJ', + iconKey: 'c-drama', + children: npcItems, + meta: npcItems.length ? String(npcItems.length) : undefined, + // Pas de sectionHeaderBefore : on reste sous le header "Personnages" du nœud PJ. + createActions: [{ + id: 'new-npc', + label: 'Nouveau PNJ', + route: `/campaigns/${campaignId}/npcs/create`, + actionIcon: 'plus' + }] + }; + const sortedArcs = [...data.arcs].sort(byName); const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => { @@ -143,5 +170,5 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T }; }); - return [...arcNodes, charactersNode]; + return [...arcNodes, charactersNode, npcsNode]; } diff --git a/web/src/app/campaigns/campaign-create/campaign-create.component.html b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html similarity index 100% rename from web/src/app/campaigns/campaign-create/campaign-create.component.html rename to web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html diff --git a/web/src/app/campaigns/campaign-create/campaign-create.component.scss b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.scss similarity index 100% rename from web/src/app/campaigns/campaign-create/campaign-create.component.scss rename to web/src/app/campaigns/campaign/campaign-create/campaign-create.component.scss diff --git a/web/src/app/campaigns/campaign-create/campaign-create.component.ts b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts similarity index 90% rename from web/src/app/campaigns/campaign-create/campaign-create.component.ts rename to web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts index 1af4b9b..c1b22b5 100644 --- a/web/src/app/campaigns/campaign-create/campaign-create.component.ts +++ b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts @@ -2,10 +2,10 @@ import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { LucideAngularModule, BookCopy, X } from 'lucide-angular'; -import { LoreService } from '../../services/lore.service'; -import { Lore } from '../../services/lore.model'; -import { GameSystemService } from '../../services/game-system.service'; -import { GameSystem } from '../../services/game-system.model'; +import { LoreService } from '../../../services/lore.service'; +import { Lore } from '../../../services/lore.model'; +import { GameSystemService } from '../../../services/game-system.service'; +import { GameSystem } from '../../../services/game-system.model'; /** * Payload émis vers le parent à la création d'une campagne. diff --git a/web/src/app/campaigns/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html similarity index 59% rename from web/src/app/campaigns/campaign-detail/campaign-detail.component.html rename to web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index 982f157..247cede 100644 --- a/web/src/app/campaigns/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -70,32 +70,75 @@ -

+
-

Personnages joueurs

- +

Personnages

-
-
- -
- {{ character.name }} - {{ characterSnippet(character) }} + +
+
+

+ + Personnages joueurs + {{ characters.length }} +

+ +
+ +
+
+ +
+ {{ character.name }} + {{ personaSnippet(character) }} +
+ +
+

Aucun personnage joueur pour le moment.

+ +
-
- -

Aucun personnage joueur pour le moment.

- + +
+
+

+ + Personnages non-joueurs + {{ npcs.length }} +

+ +
+ +
+
+ +
+ {{ npc.name }} + {{ personaSnippet(npc) }} +
+
+
+ +
+

Aucun PNJ pour le moment.

+ +
diff --git a/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss similarity index 80% rename from web/src/app/campaigns/campaign-detail/campaign-detail.component.scss rename to web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss index def1f60..fac1b10 100644 --- a/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss @@ -197,6 +197,54 @@ } +// Encart "Personnages" qui regroupe les sous-sections PJ et PNJ. +.personas-section { + + .persona-subsection + .persona-subsection { + margin-top: 1.75rem; + padding-top: 1.5rem; + border-top: 1px solid #1f2937; + } +} + +.subsection-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + + h3 { + display: flex; + align-items: center; + gap: 0.5rem; + color: #d1d5db; + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0; + + lucide-icon { color: #a78bfa; } + } + + .count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.4rem; + height: 1.4rem; + padding: 0 0.45rem; + background: #1f2937; + color: #a78bfa; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0; + text-transform: none; + margin-left: 0.25rem; + } +} + .characters-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); @@ -243,8 +291,23 @@ .empty-icon { color: #374151; } p { font-size: 0.95rem; } + + // Variante condensée pour les sous-sections PJ/PNJ — pas besoin du + // padding vertical massif quand l'encart parent en porte déjà. + &.empty-state--compact { + padding: 1.5rem 1rem; + gap: 0.75rem; + + p { + font-size: 0.85rem; + margin: 0; + } + } } +// Variante d'icône pour les cartes PNJ (rouge-violet pour différencier des PJ). +.character-icon--npc { color: #c084fc !important; } + .btn-add-first { display: flex; align-items: center; diff --git a/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts similarity index 82% rename from web/src/app/campaigns/campaign-detail/campaign-detail.component.ts rename to web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts index b696281..955e728 100644 --- a/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts @@ -2,21 +2,23 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices } from 'lucide-angular'; +import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama } from 'lucide-angular'; import { Router, RouterLink } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError, switchMap, filter, map } from 'rxjs/operators'; -import { CampaignService } from '../../services/campaign.service'; -import { LoreService } from '../../services/lore.service'; -import { GameSystemService } from '../../services/game-system.service'; -import { GameSystem } from '../../services/game-system.model'; -import { CharacterService } from '../../services/character.service'; -import { Character } from '../../services/character.model'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { PageTitleService } from '../../services/page-title.service'; -import { Campaign, Arc } from '../../services/campaign.model'; -import { Lore } from '../../services/lore.model'; -import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../campaign-tree.helper'; +import { CampaignService } from '../../../services/campaign.service'; +import { LoreService } from '../../../services/lore.service'; +import { GameSystemService } from '../../../services/game-system.service'; +import { GameSystem } from '../../../services/game-system.model'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { Character } from '../../../services/character.model'; +import { Npc } from '../../../services/npc.model'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Campaign, Arc } from '../../../services/campaign.model'; +import { Lore } from '../../../services/lore.model'; +import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper'; @Component({ selector: 'app-campaign-detail', @@ -33,6 +35,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { readonly Trash2 = Trash2; readonly User = User; readonly Dices = Dices; + readonly Drama = Drama; campaign: Campaign | null = null; arcs: Arc[] = []; @@ -48,6 +51,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { linkedGameSystem: GameSystem | null = null; /** Fiches de personnages (PJ) de la campagne. */ characters: Character[] = []; + /** Fiches de personnages non-joueurs (PNJ) de la campagne. */ + npcs: Npc[] = []; /** Mode édition inline. */ editing = false; @@ -63,6 +68,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { private loreService: LoreService, private gameSystemService: GameSystemService, private characterService: CharacterService, + private npcService: NpcService, private layoutService: LayoutService, private pageTitleService: PageTitleService ) {} @@ -77,8 +83,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).pipe( - catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData)) + treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe( + catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData)) ) })) ).subscribe(({ campaign, allCampaigns, treeData }) => { @@ -87,6 +93,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.loadLinkedLore(campaign); this.loadLinkedGameSystem(campaign); this.loadCharacters(campaign.id!); + this.loadNpcs(campaign.id!); this.arcs = treeData.arcs; this.chapterCountByArc = this.computeChapterCounts(treeData); this.showLayout(allCampaigns, treeData); @@ -111,8 +118,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(id), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe( - catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData)) + treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe( + catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData)) ) }).subscribe(({ campaign, allCampaigns, treeData }) => { this.campaign = campaign; @@ -120,6 +127,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.loadLinkedLore(campaign); this.loadLinkedGameSystem(campaign); this.loadCharacters(campaign.id!); + this.loadNpcs(campaign.id!); this.arcs = treeData.arcs; this.chapterCountByArc = this.computeChapterCounts(treeData); this.showLayout(allCampaigns, treeData); @@ -159,11 +167,28 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { ).subscribe(list => this.characters = list); } + /** Symétrique pour les PNJ. */ + private loadNpcs(campaignId: string): void { + this.npcService.getByCampaign(campaignId).pipe( + catchError(() => of([] as Npc[])) + ).subscribe(list => this.npcs = list); + } + createCharacter(): void { if (!this.campaign) return; this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']); } + createNpc(): void { + if (!this.campaign) return; + this.router.navigate(['/campaigns', this.campaign.id, 'npcs', 'create']); + } + + editNpc(npc: Npc): void { + if (!this.campaign || !npc.id) return; + this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id, 'edit']); + } + editCharacter(character: Character): void { if (!this.campaign || !character.id) return; this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']); @@ -179,10 +204,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]); } - /** Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). */ - characterSnippet(c: Character): string { - if (!c.markdownContent) return '(Fiche vide)'; - const firstMeaningful = c.markdownContent + /** + * Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). + * Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte). + */ + personaSnippet(p: { markdownContent?: string | null }): string { + if (!p.markdownContent) return '(Fiche vide)'; + const firstMeaningful = p.markdownContent .split('\n') .map(l => l.trim()) .find(l => l && !l.startsWith('#')); @@ -192,6 +220,11 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { : firstMeaningful; } + /** Alias gardé pour compatibilité avec les anciens templates. */ + characterSnippet(c: Character): string { + return this.personaSnippet(c); + } + private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void { const campaignId = this.campaign!.id!; const globalItems: GlobalItem[] = allCampaigns.map(c => ({ diff --git a/web/src/app/campaigns/campaigns.component.ts b/web/src/app/campaigns/campaigns.component.ts index 516f025..5017df5 100644 --- a/web/src/app/campaigns/campaigns.component.ts +++ b/web/src/app/campaigns/campaigns.component.ts @@ -4,7 +4,7 @@ import { Router } from '@angular/router'; import { LucideAngularModule, Map, Plus } from 'lucide-angular'; import { CampaignService } from '../services/campaign.service'; import { Campaign } from '../services/campaign.model'; -import { CampaignCreateComponent, CampaignCreatePayload } from './campaign-create/campaign-create.component'; +import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component'; @Component({ selector: 'app-campaigns', diff --git a/web/src/app/campaigns/chapter-create/chapter-create.component.html b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html similarity index 100% rename from web/src/app/campaigns/chapter-create/chapter-create.component.html rename to web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html diff --git a/web/src/app/campaigns/chapter-create/chapter-create.component.scss b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.scss similarity index 100% rename from web/src/app/campaigns/chapter-create/chapter-create.component.scss rename to web/src/app/campaigns/chapter/chapter-create/chapter-create.component.scss diff --git a/web/src/app/campaigns/chapter-create/chapter-create.component.ts b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts similarity index 83% rename from web/src/app/campaigns/chapter-create/chapter-create.component.ts rename to web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts index d397dba..291d610 100644 --- a/web/src/app/campaigns/chapter-create/chapter-create.component.ts +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts @@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule } from 'lucide-angular'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { Campaign } from '../../services/campaign.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; -import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component'; -import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { Campaign } from '../../../services/campaign.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; /** * Écran de création d'un nouveau chapitre rattaché à un arc. @@ -39,6 +40,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private layoutService: LayoutService ) { this.form = this.fb.group({ @@ -57,7 +59,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) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).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-edit/chapter-edit.component.html b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html similarity index 100% rename from web/src/app/campaigns/chapter-edit/chapter-edit.component.html rename to web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html diff --git a/web/src/app/campaigns/chapter-edit/chapter-edit.component.scss b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss similarity index 100% rename from web/src/app/campaigns/chapter-edit/chapter-edit.component.scss rename to web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss diff --git a/web/src/app/campaigns/chapter-edit/chapter-edit.component.ts b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts similarity index 84% rename from web/src/app/campaigns/chapter-edit/chapter-edit.component.ts rename to web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts index ad1f746..f186f52 100644 --- a/web/src/app/campaigns/chapter-edit/chapter-edit.component.ts +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts @@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { PageService } from '../../services/page.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { PageTitleService } from '../../services/page-title.service'; -import { Campaign, Chapter } from '../../services/campaign.model'; -import { Page } from '../../services/page.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; -import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component'; -import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component'; -import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component'; -import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component'; -import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { PageService } from '../../../services/page.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Campaign, Chapter } from '../../../services/campaign.model'; +import { Page } from '../../../services/page.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component'; +import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; +import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; +import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; /** * Écran de détail/modification d'un Chapitre. @@ -67,6 +68,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService @@ -104,7 +106,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) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/chapter-graph/chapter-graph.component.html b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html similarity index 100% rename from web/src/app/campaigns/chapter-graph/chapter-graph.component.html rename to web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html diff --git a/web/src/app/campaigns/chapter-graph/chapter-graph.component.scss b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.scss similarity index 100% rename from web/src/app/campaigns/chapter-graph/chapter-graph.component.scss rename to web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.scss diff --git a/web/src/app/campaigns/chapter-graph/chapter-graph.component.ts b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts similarity index 95% rename from web/src/app/campaigns/chapter-graph/chapter-graph.component.ts rename to web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts index 8418ad1..693364d 100644 --- a/web/src/app/campaigns/chapter-graph/chapter-graph.component.ts +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts @@ -3,12 +3,13 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { PageTitleService } from '../../services/page-title.service'; -import { Campaign, Chapter, Scene } from '../../services/campaign.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Campaign, Chapter, Scene } from '../../../services/campaign.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; } interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; } @@ -68,6 +69,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private layoutService: LayoutService, private pageTitleService: PageTitleService ) {} @@ -87,7 +89,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) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => { this.chapter = chapter; this.scenes = scenes; diff --git a/web/src/app/campaigns/chapter-view/chapter-view.component.html b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html similarity index 100% rename from web/src/app/campaigns/chapter-view/chapter-view.component.html rename to web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html diff --git a/web/src/app/campaigns/chapter-view/chapter-view.component.scss b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.scss similarity index 100% rename from web/src/app/campaigns/chapter-view/chapter-view.component.scss rename to web/src/app/campaigns/chapter/chapter-view/chapter-view.component.scss diff --git a/web/src/app/campaigns/chapter-view/chapter-view.component.ts b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts similarity index 85% rename from web/src/app/campaigns/chapter-view/chapter-view.component.ts rename to web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts index 92708b6..9bb7a82 100644 --- a/web/src/app/campaigns/chapter-view/chapter-view.component.ts +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts @@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular'; -import { resolveCampaignIcon } from '../campaign-icons'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { PageService } from '../../services/page.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { PageTitleService } from '../../services/page-title.service'; -import { Campaign, Chapter } from '../../services/campaign.model'; -import { Page } from '../../services/page.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; -import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component'; +import { resolveCampaignIcon } from '../../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { PageService } from '../../../services/page.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Campaign, Chapter } from '../../../services/campaign.model'; +import { Page } from '../../../services/page.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; /** * Écran de consultation d'un Chapitre (lecture seule). @@ -45,6 +46,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService @@ -71,7 +73,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) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/character-edit/character-edit.component.html b/web/src/app/campaigns/character/character-edit/character-edit.component.html similarity index 100% rename from web/src/app/campaigns/character-edit/character-edit.component.html rename to web/src/app/campaigns/character/character-edit/character-edit.component.html diff --git a/web/src/app/campaigns/character-edit/character-edit.component.scss b/web/src/app/campaigns/character/character-edit/character-edit.component.scss similarity index 100% rename from web/src/app/campaigns/character-edit/character-edit.component.scss rename to web/src/app/campaigns/character/character-edit/character-edit.component.scss diff --git a/web/src/app/campaigns/character-edit/character-edit.component.ts b/web/src/app/campaigns/character/character-edit/character-edit.component.ts similarity index 93% rename from web/src/app/campaigns/character-edit/character-edit.component.ts rename to web/src/app/campaigns/character/character-edit/character-edit.component.ts index 208c7b1..6ce6978 100644 --- a/web/src/app/campaigns/character-edit/character-edit.component.ts +++ b/web/src/app/campaigns/character/character-edit/character-edit.component.ts @@ -3,9 +3,9 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular'; -import { CharacterService } from '../../services/character.service'; -import { Character } from '../../services/character.model'; -import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component'; +import { CharacterService } from '../../../services/character.service'; +import { Character } from '../../../services/character.model'; +import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; /** * Éditeur plein écran d'une fiche de personnage (PJ). diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html new file mode 100644 index 0000000..53b2dc5 --- /dev/null +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html @@ -0,0 +1,82 @@ +
+ +
+ +
+

+ + {{ npcId ? 'Éditer le PNJ' : 'Nouveau PNJ' }} +

+ +
+
+ +
+ +
+ + +
+ +
+ +

+ Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ… + À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes. +

+ +
+ +
+ + + + +
+ +
+ +
+ + + diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.scss b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.scss new file mode 100644 index 0000000..0a69ca5 --- /dev/null +++ b/web/src/app/campaigns/npc/npc-edit/npc-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/npc/npc-edit/npc-edit.component.ts b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts new file mode 100644 index 0000000..8bd8028 --- /dev/null +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts @@ -0,0 +1,109 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular'; +import { NpcService } from '../../../services/npc.service'; +import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; + +/** + * Éditeur plein écran d'une fiche de PNJ. + * Double rôle création/édition : + * - `/campaigns/:campaignId/npcs/create` → POST + * - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT + * + * MVP : name + markdown libre. L'Assistant IA est branché en mode édition + * (focus entityType="npc") pour proposer apparence, motivations, secrets... + */ +@Component({ + selector: 'app-npc-edit', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent], + templateUrl: './npc-edit.component.html', + styleUrls: ['./npc-edit.component.scss'] +}) +export class NpcEditComponent implements OnInit { + readonly Save = Save; + readonly ArrowLeft = ArrowLeft; + readonly Drama = Drama; + readonly Trash2 = Trash2; + readonly Sparkles = Sparkles; + + /** État drawer chat IA focalisé sur ce PNJ. */ + chatOpen = false; + readonly chatQuickSuggestions = [ + 'Propose une apparence et une posture marquantes', + 'Suggère 2 motivations et un secret pour ce PNJ', + 'Imagine 3 répliques signatures qui le caractérisent' + ]; + + toggleChat(): void { this.chatOpen = !this.chatOpen; } + + campaignId: string | null = null; + npcId: string | null = null; + + name = ''; + markdownContent = ''; + private order = 0; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: NpcService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.npcId = params.get('npcId'); + + if (this.npcId) { + this.service.getById(this.npcId).subscribe({ + next: (n) => { + this.name = n.name; + this.markdownContent = n.markdownContent ?? ''; + this.order = n.order ?? 0; + }, + error: () => this.back() + }); + } + } + + submit(): void { + if (!this.name.trim() || !this.campaignId) return; + const req = this.npcId + ? this.service.update(this.npcId, { + id: this.npcId, + name: this.name.trim(), + markdownContent: this.markdownContent || null, + campaignId: this.campaignId, + order: this.order + }) + : this.service.create({ + name: this.name.trim(), + markdownContent: this.markdownContent || null, + campaignId: this.campaignId + }); + req.subscribe({ + next: () => this.back(), + error: () => console.error('Erreur sauvegarde Npc') + }); + } + + deleteNpc(): void { + if (!this.npcId) return; + if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return; + this.service.delete(this.npcId).subscribe({ + next: () => this.back(), + error: () => console.error('Erreur suppression Npc') + }); + } + + back(): void { + if (this.campaignId) { + this.router.navigate(['/campaigns', this.campaignId]); + } else { + this.router.navigate(['/campaigns']); + } + } +} diff --git a/web/src/app/campaigns/scene-create/scene-create.component.html b/web/src/app/campaigns/scene/scene-create/scene-create.component.html similarity index 100% rename from web/src/app/campaigns/scene-create/scene-create.component.html rename to web/src/app/campaigns/scene/scene-create/scene-create.component.html diff --git a/web/src/app/campaigns/scene-create/scene-create.component.scss b/web/src/app/campaigns/scene/scene-create/scene-create.component.scss similarity index 100% rename from web/src/app/campaigns/scene-create/scene-create.component.scss rename to web/src/app/campaigns/scene/scene-create/scene-create.component.scss diff --git a/web/src/app/campaigns/scene-create/scene-create.component.ts b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts similarity index 84% rename from web/src/app/campaigns/scene-create/scene-create.component.ts rename to web/src/app/campaigns/scene/scene-create/scene-create.component.ts index 84e1ec3..c97483e 100644 --- a/web/src/app/campaigns/scene-create/scene-create.component.ts +++ b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts @@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule } from 'lucide-angular'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { Campaign } from '../../services/campaign.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; -import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component'; -import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { Campaign } from '../../../services/campaign.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; /** * Écran de création d'une nouvelle scène rattachée à un chapitre. @@ -40,6 +41,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private layoutService: LayoutService ) { this.form = this.fb.group({ @@ -59,7 +61,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) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).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-edit/scene-edit.component.html b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html similarity index 100% rename from web/src/app/campaigns/scene-edit/scene-edit.component.html rename to web/src/app/campaigns/scene/scene-edit/scene-edit.component.html diff --git a/web/src/app/campaigns/scene-edit/scene-edit.component.scss b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss similarity index 100% rename from web/src/app/campaigns/scene-edit/scene-edit.component.scss rename to web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss diff --git a/web/src/app/campaigns/scene-edit/scene-edit.component.ts b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts similarity index 87% rename from web/src/app/campaigns/scene-edit/scene-edit.component.ts rename to web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts index adae064..fc8a6f1 100644 --- a/web/src/app/campaigns/scene-edit/scene-edit.component.ts +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts @@ -5,20 +5,21 @@ import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { PageService } from '../../services/page.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { PageTitleService } from '../../services/page-title.service'; -import { Campaign, Scene, SceneBranch } from '../../services/campaign.model'; -import { Page } from '../../services/page.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; -import { ExpandableSectionComponent } from '../../shared/expandable-section/expandable-section.component'; -import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component'; -import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component'; -import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component'; -import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component'; -import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { PageService } from '../../../services/page.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Campaign, Scene, SceneBranch } from '../../../services/campaign.model'; +import { Page } from '../../../services/page.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component'; +import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component'; +import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; +import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; +import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; /** * Écran de détail/modification d'une Scène. @@ -71,6 +72,7 @@ export class SceneEditComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService @@ -122,7 +124,7 @@ export class SceneEditComponent implements OnInit, OnDestroy { allCampaigns: this.campaignService.getAllCampaigns(), scene: this.campaignService.getSceneById(this.sceneId), chapterScenes: this.campaignService.getScenes(this.chapterId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/scene-view/scene-view.component.html b/web/src/app/campaigns/scene/scene-view/scene-view.component.html similarity index 100% rename from web/src/app/campaigns/scene-view/scene-view.component.html rename to web/src/app/campaigns/scene/scene-view/scene-view.component.html diff --git a/web/src/app/campaigns/scene-view/scene-view.component.scss b/web/src/app/campaigns/scene/scene-view/scene-view.component.scss similarity index 100% rename from web/src/app/campaigns/scene-view/scene-view.component.scss rename to web/src/app/campaigns/scene/scene-view/scene-view.component.scss diff --git a/web/src/app/campaigns/scene-view/scene-view.component.ts b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts similarity index 83% rename from web/src/app/campaigns/scene-view/scene-view.component.ts rename to web/src/app/campaigns/scene/scene-view/scene-view.component.ts index ff1a988..6f05b1b 100644 --- a/web/src/app/campaigns/scene-view/scene-view.component.ts +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts @@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular'; -import { resolveCampaignIcon } from '../campaign-icons'; -import { CampaignService } from '../../services/campaign.service'; -import { CharacterService } from '../../services/character.service'; -import { PageService } from '../../services/page.service'; -import { LayoutService, GlobalItem } from '../../services/layout.service'; -import { PageTitleService } from '../../services/page-title.service'; -import { Campaign, Scene } from '../../services/campaign.model'; -import { Page } from '../../services/page.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; -import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component'; +import { resolveCampaignIcon } from '../../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { PageService } from '../../../services/page.service'; +import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Campaign, Scene } from '../../../services/campaign.model'; +import { Page } from '../../../services/page.model'; +import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; /** * Écran de consultation d'une Scène (lecture seule). @@ -45,6 +46,7 @@ export class SceneViewComponent implements OnInit, OnDestroy { private router: Router, private campaignService: CampaignService, private characterService: CharacterService, + private npcService: NpcService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService @@ -74,7 +76,7 @@ export class SceneViewComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), scene: this.campaignService.getSceneById(this.sceneId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/services/ai-chat.service.ts b/web/src/app/services/ai-chat.service.ts index a4d9e21..75dd0a6 100644 --- a/web/src/app/services/ai-chat.service.ts +++ b/web/src/app/services/ai-chat.service.ts @@ -41,7 +41,7 @@ export type ChatStreamEvent = * décode ligne par ligne pour extraire les événements SSE. */ /** Type d'entité narrative focus pour le chat Campagne. */ -export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character'; +export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'npc'; @Injectable({ providedIn: 'root' }) export class AiChatService { diff --git a/web/src/app/services/conversation.model.ts b/web/src/app/services/conversation.model.ts index 84d283f..7fa8e0f 100644 --- a/web/src/app/services/conversation.model.ts +++ b/web/src/app/services/conversation.model.ts @@ -26,7 +26,7 @@ export interface Conversation { export interface ConversationContext { loreId?: string | null; campaignId?: string | null; - entityType?: 'page' | 'arc' | 'chapter' | 'scene' | 'character' | null; + entityType?: 'page' | 'arc' | 'chapter' | 'scene' | 'character' | 'npc' | null; entityId?: string | null; } diff --git a/web/src/app/services/npc.model.ts b/web/src/app/services/npc.model.ts new file mode 100644 index 0000000..488b116 --- /dev/null +++ b/web/src/app/services/npc.model.ts @@ -0,0 +1,18 @@ +/** + * Fiche de personnage non-joueur (PNJ) d'une campagne. + * MVP : markdownContent libre (description, motivation, stats, notes MJ). + * Évolution prévue : templating partagé PJ/PNJ piloté par GameSystem. + */ +export interface Npc { + id?: string; + name: string; + markdownContent?: string | null; + campaignId: string; + order?: number; +} + +export interface NpcCreate { + name: string; + markdownContent?: string | null; + campaignId: string; +} diff --git a/web/src/app/services/npc.service.ts b/web/src/app/services/npc.service.ts new file mode 100644 index 0000000..22f9c45 --- /dev/null +++ b/web/src/app/services/npc.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Npc, NpcCreate } from './npc.model'; + +/** + * Service HTTP pour les fiches de PNJ d'une campagne. + */ +@Injectable({ providedIn: 'root' }) +export class NpcService { + private apiUrl = '/api/npcs'; + + constructor(private http: HttpClient) {} + + getByCampaign(campaignId: string): Observable { + return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); + } + + getById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + create(payload: NpcCreate): Observable { + return this.http.post(this.apiUrl, payload); + } + + update(id: string, payload: Npc): Observable { + return this.http.put(`${this.apiUrl}/${id}`, payload); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json index a52ec02..fe320c2 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -31,5 +31,9 @@ "strictInjectionParameters": true, "strictInputAccessModifiers": true, "strictTemplates": true - } + }, + "exclude": [ + "e2e/**/*", + "playwright.config.ts" + ] }