Compare commits

..

2 Commits

Author SHA1 Message Date
4d05aa5118 Passage à la version 1.0.0
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 22s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 2m54s
Build & Push Images / build (brain) (push) Successful in 1m21s
Build & Push Images / build (core) (push) Successful in 3m37s
Build & Push Images / build (web) (push) Successful in 2m1s
E2E Tests / e2e (push) Failing after 9m37s
Build & Push Images / build-switcher (push) Successful in 42s
2026-07-04 11:21:46 +02:00
beafbc2fa9 Livret PDF v2, graphe des liens côté campagne, et refactor de l'import
Livret PDF (export campagne) :
- refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs
  en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat,
  ligne de contexte des scènes, en-tête courant, tables zébrées
- reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub
  fusionnées dans leur chapitre, partie « Quêtes » sans doublon
- contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces
  explorables, partie « Tables aléatoires »
- polices DejaVu embarquées (couverture Unicode, licence incluse) et harness
  visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres)

Graphe des liens :
- déplacé du Lore vers la campagne (les PNJ sont des entités de campagne) ;
  endpoint /api/npcs/lore/{id} supprimé
- layout retravaillé (anti-chevauchement des libellés, éventail des feuilles
  autour des hubs), scènes et quêtes ajoutées, libellés sur 2 lignes,
  filtres de légende mémorisés, focus au survol
- positions drag & drop persistées sur la campagne (migration V23,
  PUT /api/campaigns/{id}/graph-positions, bouton « Disposition auto »)

Divers :
- ImportService découpé en classes dédiées (parser d'archive, remapper d'ids,
  inserters campagne/lore/état de jeu, conversion legacy des quêtes)
- fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans
- garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs),
  budget SCSS anyComponentStyle relevé à 12 ko
2026-07-04 11:15:16 +02:00
72 changed files with 3725 additions and 1514 deletions

View File

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

View File

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

View File

@@ -92,6 +92,22 @@ public class CampaignService {
return campaignRepository.save(campaign); return campaignRepository.save(campaign);
} }
/**
* Sauvegarde les positions du graphe de campagne (JSON opaque, état de
* présentation possédé par le front). Null/vide = retour à la disposition
* automatique. Taille bornée : ce champ ne doit pas devenir un fourre-tout.
*/
public void updateGraphPositions(String id, String positionsJson) {
Campaign campaign = campaignRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Campaign non trouvé avec l'ID: " + id));
String value = (positionsJson == null || positionsJson.isBlank()) ? null : positionsJson;
if (value != null && value.length() > 200_000) {
throw new IllegalArgumentException("Positions de graphe trop volumineuses");
}
campaign.setGraphPositions(value);
campaignRepository.save(campaign);
}
private String normalizeId(String id) { private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id; return (id == null || id.isBlank()) ? null : id;
} }

View File

@@ -1,9 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -20,11 +18,9 @@ import java.util.Optional;
public class NpcService { public class NpcService {
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final CampaignRepository campaignRepository;
public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) { public NpcService(NpcRepository npcRepository) {
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.campaignRepository = campaignRepository;
} }
public record NpcData( public record NpcData(
@@ -67,21 +63,6 @@ public class NpcService {
return npcRepository.findByCampaignId(campaignId); return npcRepository.findByCampaignId(campaignId);
} }
/**
* PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}).
* Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent.
* Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé.
*/
public List<Npc> getNpcsByLoreId(String loreId) {
List<Npc> out = new ArrayList<>();
for (Campaign campaign : campaignRepository.findAll()) {
if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) {
out.addAll(npcRepository.findByCampaignId(campaign.getId()));
}
}
return out;
}
public Npc updateNpc(String id, NpcData data) { public Npc updateNpc(String id, NpcData data) {
Npc existing = npcRepository.findById(id) Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));

View File

@@ -38,6 +38,12 @@ public class Campaign {
*/ */
private String gameSystemId; private String gameSystemId;
/**
* Positions des nœuds du graphe de campagne (JSON opaque, état de
* présentation possédé par le front). Null = disposition automatique.
*/
private String graphPositions;
public boolean isLinkedToLore() { public boolean isLinkedToLore() {
return this.loreId != null && !this.loreId.isBlank(); return this.loreId != null && !this.loreId.isBlank();
} }

View File

@@ -55,6 +55,13 @@ public class CampaignJpaEntity {
@Column(name = "game_system_id") @Column(name = "game_system_id")
private String gameSystemId; private String gameSystemId;
/**
* Positions personnalisées des nœuds du graphe de campagne (JSON opaque,
* possédé par le front). Null = disposition automatique.
*/
@Column(name = "graph_positions", columnDefinition = "TEXT")
private String graphPositions;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();

View File

@@ -76,6 +76,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.playerCount(jpaEntity.getPlayerCount()) .playerCount(jpaEntity.getPlayerCount())
.loreId(jpaEntity.getLoreId()) .loreId(jpaEntity.getLoreId())
.gameSystemId(jpaEntity.getGameSystemId()) .gameSystemId(jpaEntity.getGameSystemId())
.graphPositions(jpaEntity.getGraphPositions())
.build(); .build();
} }
@@ -91,6 +92,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.playerCount(campaign.getPlayerCount()) .playerCount(campaign.getPlayerCount())
.loreId(campaign.getLoreId()) .loreId(campaign.getLoreId())
.gameSystemId(campaign.getGameSystemId()) .gameSystemId(campaign.getGameSystemId())
.graphPositions(campaign.getGraphPositions())
.build(); .build();
} }
} }

View File

@@ -0,0 +1,294 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.campaigncontext.SceneBattlemap;
import com.loremind.domain.campaigncontext.SceneType;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter;
import com.loremind.infrastructure.persistence.entity.ArcJpaEntity;
import com.loremind.infrastructure.persistence.entity.CampaignJpaEntity;
import com.loremind.infrastructure.persistence.entity.CatalogItemJpaEntity;
import com.loremind.infrastructure.persistence.entity.ChapterJpaEntity;
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
import com.loremind.infrastructure.persistence.entity.ItemCatalogJpaEntity;
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.entity.QuestJpaEntity;
import com.loremind.infrastructure.persistence.entity.RandomTableEntryJpaEntity;
import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity;
import com.loremind.infrastructure.persistence.entity.SceneJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ArcJpaRepository;
import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository;
import com.loremind.infrastructure.persistence.jpa.ChapterJpaRepository;
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
import com.loremind.infrastructure.persistence.jpa.ItemCatalogJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import com.loremind.infrastructure.persistence.jpa.QuestJpaRepository;
import com.loremind.infrastructure.persistence.jpa.RandomTableJpaRepository;
import com.loremind.infrastructure.persistence.jpa.SceneJpaRepository;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 1re passe d'import du contenu de campagne (cf. {@link ImportService}) : Campaign,
* Arc, ItemCatalog, RandomTable, Chapter, Quest (v2 ou conversion legacy via
* {@link LegacyQuestConverter}), Npc, Enemy, Scene. Alimente les maps de remapping
* correspondantes de {@link ImportIdMaps}.
*/
@Component
class CampaignContentInserter {
// (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite
// est scellé, non sérialisable en polymorphe par l'ObjectMapper standard).
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
private static final QuestNodeListJsonConverter NODE_CONVERTER = new QuestNodeListJsonConverter();
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo;
private final ChapterJpaRepository chapterRepo;
private final QuestJpaRepository questRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final SceneJpaRepository sceneRepo;
private final LegacyQuestConverter legacyQuestConverter;
CampaignContentInserter(CampaignJpaRepository campaignRepo,
ArcJpaRepository arcRepo,
ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo,
ChapterJpaRepository chapterRepo,
QuestJpaRepository questRepo,
NpcJpaRepository npcRepo,
EnemyJpaRepository enemyRepo,
SceneJpaRepository sceneRepo,
LegacyQuestConverter legacyQuestConverter) {
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo;
this.chapterRepo = chapterRepo;
this.questRepo = questRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.sceneRepo = sceneRepo;
this.legacyQuestConverter = legacyQuestConverter;
}
void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
// -- Campaign (loreId/gameSystemId String remappes en 2e passe)
for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) {
CampaignJpaEntity e = new CampaignJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcsCount(d.arcsCount());
e.setPlayerCount(d.playerCount());
e.setLoreId(d.loreId()); // remappe en 2e passe
e.setGameSystemId(d.gameSystemId()); // remappe en 2e passe
maps.campaignMap.put(d.id(), campaignRepo.save(e).getId());
}
result.count("campaigns", maps.campaignMap.size());
// -- Arc (relatedPageIds remappe en 2e passe)
for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
ArcJpaEntity e = new ArcJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
e.setOrder(d.order());
e.setType(IdRemapper.parseArcType(d.type()));
e.setIcon(d.icon());
e.setThemes(d.themes());
e.setStakes(d.stakes());
e.setGmNotes(d.gmNotes());
e.setRewards(d.rewards());
e.setResolution(d.resolution());
e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe
e.setIllustrationImageIds(d.illustrationImageIds());
maps.arcMap.put(d.id(), arcRepo.save(e).getId());
}
result.count("arcs", maps.arcMap.size());
// -- ItemCatalog (+ items en cascade)
int catalogCount = 0, itemCount = 0;
for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) {
ItemCatalogJpaEntity e = new ItemCatalogJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setIcon(d.icon());
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
e.setOrder(d.order());
List<CatalogItemJpaEntity> items = new ArrayList<>();
for (ContentExport.CatalogItemDto i : nullSafe(d.items())) {
CatalogItemJpaEntity item = new CatalogItemJpaEntity();
item.setName(i.name());
item.setPrice(i.price());
item.setCategory(i.category());
item.setDescription(i.description());
item.setPosition(i.position());
item.setCatalog(e); // lien parent requis pour la cascade
items.add(item);
itemCount++;
}
e.setItems(items);
itemCatalogRepo.save(e);
catalogCount++;
}
result.count("itemCatalogs", catalogCount);
result.count("catalogItems", itemCount);
// -- RandomTable (+ entries en cascade)
int tableCount = 0, entryCount = 0;
for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) {
RandomTableJpaEntity e = new RandomTableJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setDiceFormula(d.diceFormula());
e.setIcon(d.icon());
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
e.setOrder(d.order());
List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) {
RandomTableEntryJpaEntity entryE = new RandomTableEntryJpaEntity();
entryE.setMinRoll(en.minRoll());
entryE.setMaxRoll(en.maxRoll());
entryE.setLabel(en.label());
entryE.setDetail(en.detail());
entryE.setPosition(en.position());
entryE.setRandomTable(e);
entries.add(entryE);
entryCount++;
}
e.setEntries(entries);
randomTableRepo.save(e);
tableCount++;
}
result.count("randomTables", tableCount);
result.count("randomTableEntries", entryCount);
// -- Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de
// prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy.
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
ChapterJpaEntity e = new ChapterJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcId(IdRemapper.remapId(maps.arcMap, d.arcId()));
e.setOrder(d.order());
e.setIcon(d.icon());
e.setGmNotes(d.gmNotes());
e.setPlayerObjectives(d.playerObjectives());
e.setNarrativeStakes(d.narrativeStakes());
e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe
e.setIllustrationImageIds(d.illustrationImageIds());
maps.chapterMap.put(d.id(), chapterRepo.save(e).getId());
}
result.count("chapters", maps.chapterMap.size());
// -- Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ;
// prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt).
for (ContentExport.QuestDto d : nullSafe(export.quests())) {
QuestJpaEntity e = new QuestJpaEntity();
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
e.setArcId(IdRemapper.remapId(maps.arcMap, d.arcId())); // arcMap déjà prêt (arcs importés avant) ; null→null
e.setName(d.name());
e.setDescription(d.description());
e.setIcon(d.icon());
e.setOrder(d.order());
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappé 2e passe
e.setNodes(NODE_CONVERTER.convertToEntityAttribute(d.nodesJson())); // remappé 2e passe
e.setGmNotes(d.gmNotes());
e.setPlayerObjectives(d.playerObjectives());
e.setNarrativeStakes(d.narrativeStakes());
e.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe
e.setIllustrationImageIds(d.illustrationImageIds());
maps.questMap.put(d.id(), questRepo.save(e).getId());
}
// -- Quest legacy (bundle SANS champ quests) : conversion des chapitres qui jouaient
// le rôle de quête. Ici questMap est clé par ANCIEN CHAPTER id (pas de collision :
// on compare chapter-id à chapter-id).
if (export.quests() == null) {
legacyQuestConverter.convertLegacyChaptersToQuests(export, maps);
}
result.count("quests", maps.questMap.size());
// -- Npc (relatedPageIds remappe en 2e passe)
for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
NpcJpaEntity e = new NpcJpaEntity();
e.setName(d.name());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe
e.setFolder(d.folder());
e.setOrder(d.order());
maps.npcMap.put(d.id(), npcRepo.save(e).getId());
}
result.count("npcs", maps.npcMap.size());
// -- Enemy
for (ContentExport.EnemyDto d : nullSafe(export.enemies())) {
EnemyJpaEntity e = new EnemyJpaEntity();
e.setName(d.name());
e.setLevel(d.level());
e.setFolder(d.folder());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
e.setFoundryRef(d.foundryRef()); // ref externe Foundry : conservee telle quelle
e.setFoundryStats(d.foundryStats());
e.setOrder(d.order());
maps.enemyMap.put(d.id(), enemyRepo.save(e).getId());
}
result.count("enemies", maps.enemyMap.size());
// -- Scene (enemyIds + relatedPageIds + branches remappes en 2e passe)
for (ContentExport.SceneDto d : nullSafe(export.scenes())) {
SceneJpaEntity e = new SceneJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setChapterId(IdRemapper.remapId(maps.chapterMap, d.chapterId()));
e.setOrder(d.order());
e.setIcon(d.icon());
e.setType(d.type() != null ? d.type() : SceneType.GENERIC);
e.setLocation(d.location());
e.setTiming(d.timing());
e.setAtmosphere(d.atmosphere());
e.setPlayerNarration(d.playerNarration());
e.setGmSecretNotes(d.gmSecretNotes());
e.setChoicesConsequences(d.choicesConsequences());
e.setCombatDifficulty(d.combatDifficulty());
e.setEnemies(d.enemies());
e.setEnemyIds(d.enemyIds()); // remappe en 2e passe
e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe
e.setIllustrationImageIds(d.illustrationImageIds());
// Battlemaps : ids StoredFile passes tels quels (meme logique que les refs
// d'images illustration, non remappees). Les exports anterieurs a V22
// portaient une paire unique -> reconstituee en premiere entree de liste.
List<SceneBattlemap> battlemaps = d.battlemaps();
if ((battlemaps == null || battlemaps.isEmpty())
&& (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) {
battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId()));
}
e.setBattlemaps(battlemaps != null ? battlemaps : List.of());
e.setGraphX(d.graphX());
e.setGraphY(d.graphY());
e.setBranches(d.branches()); // remappe en 2e passe
e.setRooms(d.rooms()); // Rooms: UUID, non remappes
maps.sceneMap.put(d.id(), sceneRepo.save(e).getId());
}
result.count("scenes", maps.sceneMap.size());
}
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
}

View File

@@ -0,0 +1,77 @@
package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* Déballage d'un zip d'import (cf. {@link ImportService}) : {@code data.json}
* désérialisé en {@link ContentExport}, binaires images/fichiers gardés en mémoire
* sous leur clé de stockage d'origine.
*/
@Component
class ImportArchiveParser {
/** Contenu déballé d'un zip d'import : {@code data.json} + binaires images + fichiers. */
record ParsedArchive(ContentExport export,
Map<String, byte[]> imageBinaries,
Map<String, byte[]> fileBinaries) {
}
private final ObjectMapper objectMapper;
ImportArchiveParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* Déballe le zip : {@code data.json → ContentExport} et {@code images/<clé> → binaire}
* (le {@code manifest.json} est ignoré, info seulement). Lève si {@code data.json} manque.
*/
ParsedArchive parse(InputStream zipStream) {
ContentExport export = null;
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
Map<String, byte[]> fileBinaries = new LinkedHashMap<>(); // storageKey -> binaire
try (ZipInputStream zip = new ZipInputStream(zipStream)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if ("data.json".equals(name)) {
export = objectMapper.readValue(readAll(zip), ContentExport.class);
} else if (name.startsWith("images/") && !entry.isDirectory()) {
// La cle de stockage est le chemin sans le prefixe "images/" du zip,
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
String storageKey = name.substring("images/".length());
imageBinaries.put(storageKey, readAll(zip));
} else if (name.startsWith("files/") && !entry.isDirectory()) {
// Le prefixe zip "files/" enrobe le storageKey, lui-meme "files/UUID.ext" :
// on retire UNE fois le prefixe pour retrouver la cle d'origine.
String storageKey = name.substring("files/".length());
fileBinaries.put(storageKey, readAll(zip));
}
zip.closeEntry();
}
} catch (IOException e) {
throw new UncheckedIOException("Echec de lecture du zip d'import", e);
}
if (export == null) {
throw new IllegalArgumentException("Archive invalide : data.json introuvable");
}
return new ParsedArchive(export, imageBinaries, fileBinaries);
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
in.transferTo(buffer);
return buffer.toByteArray();
}
}

View File

@@ -0,0 +1,42 @@
package com.loremind.infrastructure.transfer;
import com.loremind.infrastructure.persistence.entity.LoreNodeJpaEntity;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* État partagé entre les phases d'un import (cf. {@link ImportService}) : maps de
* remapping {@code oldId → newId} par type, alimentées par les inserters (1re passe)
* et consommées par {@link ImportReferenceRemapper} (2e passe), plus les entités dont
* une référence n'a pas pu être résolue à l'insertion.
*/
final class ImportIdMaps {
final Map<Long, Long> gameSystemMap = new HashMap<>();
final Map<Long, Long> loreMap = new HashMap<>();
final Map<Long, Long> loreNodeMap = new HashMap<>();
final Map<Long, Long> templateMap = new HashMap<>();
final Map<Long, Long> pageMap = new HashMap<>();
final Map<Long, Long> campaignMap = new HashMap<>();
final Map<Long, Long> arcMap = new HashMap<>();
final Map<Long, Long> chapterMap = new HashMap<>();
final Map<Long, Long> npcMap = new HashMap<>();
final Map<Long, Long> enemyMap = new HashMap<>();
final Map<Long, Long> characterMap = new HashMap<>();
final Map<Long, Long> sceneMap = new HashMap<>();
final Map<Long, Long> questMap = new HashMap<>();
final Map<Long, Long> playthroughMap = new HashMap<>();
final Map<Long, Long> sessionMap = new HashMap<>();
final Map<Long, Long> clockMap = new HashMap<>();
final Map<Long, Long> frontMap = new HashMap<>();
/** LoreNodes sauvés avec leur parentId d'origine, à remapper en 2e passe. */
final List<LoreNodeJpaEntity> loreNodesToFix = new ArrayList<>();
/** Templates du bundle portant un defaultNodeId, à remapper en 2e passe. */
final List<ContentExport.TemplateDto> templatesWithDefaultNode = new ArrayList<>();
}

View File

@@ -0,0 +1,157 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.playcontext.ClockTrigger;
import com.loremind.infrastructure.persistence.entity.LoreNodeJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ArcJpaRepository;
import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository;
import com.loremind.infrastructure.persistence.jpa.ChapterJpaRepository;
import com.loremind.infrastructure.persistence.jpa.ClockJpaRepository;
import com.loremind.infrastructure.persistence.jpa.LoreNodeJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import com.loremind.infrastructure.persistence.jpa.PageJpaRepository;
import com.loremind.infrastructure.persistence.jpa.QuestJpaRepository;
import com.loremind.infrastructure.persistence.jpa.SceneJpaRepository;
import com.loremind.infrastructure.persistence.jpa.TemplateJpaRepository;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Component;
/**
* 2e passe d'un import (cf. {@link ImportService}) : remappe les références qui
* pointent vers des types insérés plus tard (parentId, defaultNodeId, refs faibles
* String) puis re-save. Les références vers un id absent des maps (ex. relatedPageId
* hors export) sont CONSERVÉES telles quelles.
*/
@Component
class ImportReferenceRemapper {
private final LoreNodeJpaRepository loreNodeRepo;
private final TemplateJpaRepository templateRepo;
private final PageJpaRepository pageRepo;
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
private final NpcJpaRepository npcRepo;
private final SceneJpaRepository sceneRepo;
private final QuestJpaRepository questRepo;
private final ClockJpaRepository clockRepo;
ImportReferenceRemapper(LoreNodeJpaRepository loreNodeRepo,
TemplateJpaRepository templateRepo,
PageJpaRepository pageRepo,
CampaignJpaRepository campaignRepo,
ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo,
NpcJpaRepository npcRepo,
SceneJpaRepository sceneRepo,
QuestJpaRepository questRepo,
ClockJpaRepository clockRepo) {
this.loreNodeRepo = loreNodeRepo;
this.templateRepo = templateRepo;
this.pageRepo = pageRepo;
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
this.npcRepo = npcRepo;
this.sceneRepo = sceneRepo;
this.questRepo = questRepo;
this.clockRepo = clockRepo;
}
void remap(ImportIdMaps maps) {
// LoreNode.parentId
for (LoreNodeJpaEntity e : maps.loreNodesToFix) {
Long newParent = maps.loreNodeMap.get(e.getParentId());
if (newParent != null) {
e.setParentId(newParent);
loreNodeRepo.save(e);
}
}
// Template.defaultNodeId
for (ContentExport.TemplateDto d : maps.templatesWithDefaultNode) {
Long newTemplateId = maps.templateMap.get(d.id());
Long newNode = maps.loreNodeMap.get(d.defaultNodeId());
if (newTemplateId != null && newNode != null) {
templateRepo.findById(newTemplateId).ifPresent(t -> {
t.setDefaultNodeId(newNode);
templateRepo.save(t);
});
}
}
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long).
for (Long newCampaignId : maps.campaignMap.values()) {
campaignRepo.findById(newCampaignId).ifPresent(c -> {
String newLore = IdRemapper.remapStringId(maps.loreMap, c.getLoreId());
String newGs = IdRemapper.remapStringId(maps.gameSystemMap, c.getGameSystemId());
c.setLoreId(newLore);
c.setGameSystemId(newGs);
campaignRepo.save(c);
});
}
// Page.relatedPageIds
for (Long newPageId : maps.pageMap.values()) {
pageRepo.findById(newPageId).ifPresent(p -> {
p.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, p.getRelatedPageIds()));
pageRepo.save(p);
});
}
// Arc.relatedPageIds
for (Long newArcId : maps.arcMap.values()) {
arcRepo.findById(newArcId).ifPresent(a -> {
a.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, a.getRelatedPageIds()));
arcRepo.save(a);
});
}
// Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1)
for (Long newChapterId : maps.chapterMap.values()) {
chapterRepo.findById(newChapterId).ifPresent(c -> {
c.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, c.getRelatedPageIds()));
chapterRepo.save(c);
});
}
// Npc.relatedPageIds
for (Long newNpcId : maps.npcMap.values()) {
npcRepo.findById(newNpcId).ifPresent(n -> {
n.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, n.getRelatedPageIds()));
npcRepo.save(n);
});
}
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene)
for (Long newSceneId : maps.sceneMap.values()) {
sceneRepo.findById(newSceneId).ifPresent(s -> {
s.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, s.getRelatedPageIds()));
s.setEnemyIds(IdRemapper.remapStringList(maps.enemyMap, s.getEnemyIds()));
s.setBranches(IdRemapper.remapBranches(maps.sceneMap, s.getBranches()));
sceneRepo.save(s);
});
}
// Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap),
// relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici.
for (Long newQuestId : maps.questMap.values()) {
questRepo.findById(newQuestId).ifPresent(q -> {
q.setPrerequisites(IdRemapper.remapPrerequisites(maps.questMap, q.getPrerequisites()));
q.setNodes(IdRemapper.remapQuestNodes(maps.chapterMap, maps.sceneMap, q.getNodes()));
q.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, q.getRelatedPageIds()));
questRepo.save(q);
});
}
// Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée
// (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper).
for (Long newClockId : maps.clockMap.values()) {
clockRepo.findById(newClockId).ifPresent(c -> {
if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) {
c.setTriggerRef(IdRemapper.remapStringId(maps.questMap, c.getTriggerRef()));
clockRepo.save(c);
}
});
}
}
}

View File

@@ -1,52 +1,32 @@
package com.loremind.infrastructure.transfer; package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.SceneBattlemap;
import com.loremind.domain.campaigncontext.SceneType;
import com.loremind.domain.playcontext.ClockTrigger;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.loremind.infrastructure.transfer.dto.ContentExport; import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/** /**
* Service d'IMPORT de contenu en mode FUSION. * Façade d'IMPORT de contenu en mode FUSION.
* <p> * <p>
* On NE remplace PAS l'existant : chaque entite importee est reinseree avec un * On NE remplace PAS l'existant : chaque entite importee est reinseree avec un
* NOUVEL id auto-genere, et toutes les references (FK Long et refs faibles * NOUVEL id auto-genere, et toutes les references (FK Long et refs faibles
* String) sont remappees oldId -> newId. Cela permet d'agreger plusieurs exports * String) sont remappees oldId -> newId. Cela permet d'agreger plusieurs exports
* dans une meme base sans collision, entre Postgres et H2. * dans une meme base sans collision, entre Postgres et H2.
* <p> * <p>
* Algorithme : * Algorithme, chaque phase etant portee par un composant dedie :
* <ol> * <ol>
* <li>Parse le zip : {@code data.json} -> {@link ContentExport}, binaires gardes en memoire.</li> * <li>{@link ImportArchiveParser} : {@code data.json} -> {@link ContentExport},
* <li>Reecrit les images sous LEUR CLE D'ORIGINE (pas de remapping de cle) ; * binaires gardes en memoire.</li>
* skip si une ImageJpaEntity avec cette cle existe deja.</li> * <li>{@link ImageImporter} / {@link StoredFileImporter} : reecrit les binaires
* <li>Insere top-down en construisant les maps de remapping par type.</li> * sous LEUR CLE D'ORIGINE (pas de remapping de cle) ; skip si la cle existe deja.</li>
* <li>2e passe : remappe les references qui pointent vers des types inserees * <li>Insertion top-down en construisant les maps de remapping par type
* plus tard (parentId, defaultNodeId, refs faibles String) puis re-save.</li> * ({@link ImportIdMaps}) : {@link LoreContentInserter} (referentiel lore + systemes),
* {@link CampaignContentInserter} (contenu de campagne, quetes legacy comprises),
* {@link PlayStateInserter} (etat de jeu).</li>
* <li>{@link ImportReferenceRemapper} : 2e passe, remappe les references qui pointent
* vers des types inserees plus tard (parentId, defaultNodeId, refs faibles String)
* puis re-save.</li>
* </ol> * </ol>
* Les references vers un id absent des maps (ex. relatedPageId hors export) sont * Les references vers un id absent des maps (ex. relatedPageId hors export) sont
* CONSERVEES telles quelles (choix : ne pas perdre d'info, ne jamais planter). * CONSERVEES telles quelles (choix : ne pas perdre d'info, ne jamais planter).
@@ -54,93 +34,34 @@ import java.util.zip.ZipInputStream;
@Service @Service
public class ImportService { public class ImportService {
// (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite private final ImportArchiveParser archiveParser;
// est scellé, non sérialisable en polymorphe par l'ObjectMapper standard).
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
private static final QuestNodeListJsonConverter NODE_CONVERTER = new QuestNodeListJsonConverter();
private final GameSystemJpaRepository gameSystemRepo;
private final LoreJpaRepository loreRepo;
private final LoreNodeJpaRepository loreNodeRepo;
private final TemplateJpaRepository templateRepo;
private final PageJpaRepository pageRepo;
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
private final SceneJpaRepository sceneRepo;
private final CharacterJpaRepository characterRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo;
private final PlaythroughJpaRepository playthroughRepo;
private final SessionJpaRepository sessionRepo;
private final SessionEntryJpaRepository sessionEntryRepo;
private final PlaythroughFlagJpaRepository playthroughFlagRepo;
private final QuestProgressionJpaRepository questProgressionRepo;
private final QuestJpaRepository questRepo;
private final ClockJpaRepository clockRepo;
private final FrontJpaRepository frontRepo;
private final ImageImporter imageImporter; private final ImageImporter imageImporter;
private final StoredFileImporter storedFileImporter; private final StoredFileImporter storedFileImporter;
private final ObjectMapper objectMapper; private final LoreContentInserter loreContentInserter;
private final CampaignContentInserter campaignContentInserter;
private final PlayStateInserter playStateInserter;
private final ImportReferenceRemapper referenceRemapper;
public ImportService(GameSystemJpaRepository gameSystemRepo, public ImportService(ImportArchiveParser archiveParser,
LoreJpaRepository loreRepo,
LoreNodeJpaRepository loreNodeRepo,
TemplateJpaRepository templateRepo,
PageJpaRepository pageRepo,
CampaignJpaRepository campaignRepo,
ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo,
SceneJpaRepository sceneRepo,
CharacterJpaRepository characterRepo,
NpcJpaRepository npcRepo,
EnemyJpaRepository enemyRepo,
ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo,
PlaythroughJpaRepository playthroughRepo,
SessionJpaRepository sessionRepo,
SessionEntryJpaRepository sessionEntryRepo,
PlaythroughFlagJpaRepository playthroughFlagRepo,
QuestProgressionJpaRepository questProgressionRepo,
QuestJpaRepository questRepo,
ClockJpaRepository clockRepo,
FrontJpaRepository frontRepo,
ImageImporter imageImporter, ImageImporter imageImporter,
StoredFileImporter storedFileImporter, StoredFileImporter storedFileImporter,
ObjectMapper objectMapper) { LoreContentInserter loreContentInserter,
this.gameSystemRepo = gameSystemRepo; CampaignContentInserter campaignContentInserter,
this.loreRepo = loreRepo; PlayStateInserter playStateInserter,
this.loreNodeRepo = loreNodeRepo; ImportReferenceRemapper referenceRemapper) {
this.templateRepo = templateRepo; this.archiveParser = archiveParser;
this.pageRepo = pageRepo;
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
this.sceneRepo = sceneRepo;
this.characterRepo = characterRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo;
this.playthroughRepo = playthroughRepo;
this.sessionRepo = sessionRepo;
this.sessionEntryRepo = sessionEntryRepo;
this.playthroughFlagRepo = playthroughFlagRepo;
this.questProgressionRepo = questProgressionRepo;
this.questRepo = questRepo;
this.clockRepo = clockRepo;
this.frontRepo = frontRepo;
this.imageImporter = imageImporter; this.imageImporter = imageImporter;
this.storedFileImporter = storedFileImporter; this.storedFileImporter = storedFileImporter;
this.objectMapper = objectMapper; this.loreContentInserter = loreContentInserter;
this.campaignContentInserter = campaignContentInserter;
this.playStateInserter = playStateInserter;
this.referenceRemapper = referenceRemapper;
} }
@Transactional @Transactional
public ImportResult importZip(InputStream zipStream) { public ImportResult importZip(InputStream zipStream) {
// 1. Parse du zip. // 1. Parse du zip.
ParsedArchive archive = parseArchive(zipStream); ImportArchiveParser.ParsedArchive archive = archiveParser.parse(zipStream);
ContentExport export = archive.export(); ContentExport export = archive.export();
ImportResult.Builder result = new ImportResult.Builder(); ImportResult.Builder result = new ImportResult.Builder();
@@ -150,659 +71,14 @@ public class ImportService {
storedFileImporter.importFiles(export, archive.fileBinaries(), result); storedFileImporter.importFiles(export, archive.fileBinaries(), result);
// 3. Insertion top-down + maps de remapping. // 3. Insertion top-down + maps de remapping.
Map<Long, Long> gameSystemMap = new HashMap<>(); ImportIdMaps maps = new ImportIdMaps();
Map<Long, Long> loreMap = new HashMap<>(); loreContentInserter.insert(export, maps, result);
Map<Long, Long> loreNodeMap = new HashMap<>(); campaignContentInserter.insert(export, maps, result);
Map<Long, Long> templateMap = new HashMap<>(); playStateInserter.insert(export, maps, result);
Map<Long, Long> pageMap = new HashMap<>();
Map<Long, Long> campaignMap = new HashMap<>();
Map<Long, Long> arcMap = new HashMap<>();
Map<Long, Long> chapterMap = new HashMap<>();
Map<Long, Long> npcMap = new HashMap<>();
Map<Long, Long> enemyMap = new HashMap<>();
Map<Long, Long> characterMap = new HashMap<>();
Map<Long, Long> sceneMap = new HashMap<>();
Map<Long, Long> questMap = new HashMap<>();
Map<Long, Long> playthroughMap = new HashMap<>();
Map<Long, Long> sessionMap = new HashMap<>();
Map<Long, Long> clockMap = new HashMap<>();
Map<Long, Long> frontMap = new HashMap<>();
// -- GameSystem // 4. 2e passe de remapping.
for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) { referenceRemapper.remap(maps);
GameSystemJpaEntity e = new GameSystemJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setRulesMarkdown(d.rulesMarkdown());
e.setCharacterTemplate(d.characterTemplate());
e.setNpcTemplate(d.npcTemplate());
e.setEnemyTemplate(d.enemyTemplate());
e.setFoundryActorType(d.foundryActorType());
e.setAuthor(d.author());
e.setPublic(d.isPublic());
gameSystemMap.put(d.id(), gameSystemRepo.save(e).getId());
}
result.count("gameSystems", gameSystemMap.size());
// -- Lore
for (ContentExport.LoreDto d : nullSafe(export.lores())) {
LoreJpaEntity e = new LoreJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setNodeCount(d.nodeCount());
e.setPageCount(d.pageCount());
loreMap.put(d.id(), loreRepo.save(e).getId());
}
result.count("lores", loreMap.size());
// -- LoreNode (parentId remappe en 2e passe)
List<LoreNodeJpaEntity> loreNodesToFix = new ArrayList<>();
for (ContentExport.LoreNodeDto d : nullSafe(export.loreNodes())) {
LoreNodeJpaEntity e = new LoreNodeJpaEntity();
e.setName(d.name());
e.setIcon(d.icon());
e.setParentId(d.parentId()); // remappe plus bas
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
LoreNodeJpaEntity saved = loreNodeRepo.save(e);
loreNodeMap.put(d.id(), saved.getId());
if (d.parentId() != null) loreNodesToFix.add(saved);
}
result.count("loreNodes", loreNodeMap.size());
// -- Template (defaultNodeId remappe en 2e passe)
List<ContentExport.TemplateDto> templatesWithDefaultNode = new ArrayList<>();
for (ContentExport.TemplateDto d : nullSafe(export.templates())) {
TemplateJpaEntity e = new TemplateJpaEntity();
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
e.setName(d.name());
e.setDescription(d.description());
e.setDefaultNodeId(d.defaultNodeId()); // remappe plus bas
e.setFields(d.fields());
templateMap.put(d.id(), templateRepo.save(e).getId());
if (d.defaultNodeId() != null) templatesWithDefaultNode.add(d);
}
result.count("templates", templateMap.size());
// -- Page (relatedPageIds remappe en 2e passe)
for (ContentExport.PageDto d : nullSafe(export.pages())) {
PageJpaEntity e = new PageJpaEntity();
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
e.setNodeId(IdRemapper.remapId(loreNodeMap, d.nodeId()));
e.setTemplateId(IdRemapper.remapId(templateMap, d.templateId()));
e.setTitle(d.title());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setImageFraming(d.imageFraming());
e.setKeyValueValues(d.keyValueValues());
e.setTableValues(d.tableValues());
e.setNotes(d.notes());
e.setTags(d.tags());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
pageMap.put(d.id(), pageRepo.save(e).getId());
}
result.count("pages", pageMap.size());
// -- Campaign (loreId/gameSystemId String remappes en 2e passe)
for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) {
CampaignJpaEntity e = new CampaignJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcsCount(d.arcsCount());
e.setPlayerCount(d.playerCount());
e.setLoreId(d.loreId()); // remappe plus bas
e.setGameSystemId(d.gameSystemId()); // remappe plus bas
campaignMap.put(d.id(), campaignRepo.save(e).getId());
}
result.count("campaigns", campaignMap.size());
// -- Playthrough (Partie) : campaignId remappe
for (ContentExport.PlaythroughDto d : nullSafe(export.playthroughs())) {
PlaythroughJpaEntity e = new PlaythroughJpaEntity();
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setName(d.name());
e.setDescription(d.description());
playthroughMap.put(d.id(), playthroughRepo.save(e).getId());
}
result.count("playthroughs", playthroughMap.size());
// -- Session : campaignId (ref faible String) + playthroughId remappes
for (ContentExport.SessionDto d : nullSafe(export.sessions())) {
SessionJpaEntity e = new SessionJpaEntity();
e.setName(d.name());
e.setCampaignId(IdRemapper.remapStringId(campaignMap, d.campaignId()));
e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
e.setStartedAt(parseDateTime(d.startedAt()));
e.setEndedAt(parseDateTime(d.endedAt()));
sessionMap.put(d.id(), sessionRepo.save(e).getId());
}
result.count("sessions", sessionMap.size());
// -- SessionEntry : sessionId (ref faible String) remappe
int sessionEntryCount = 0;
for (ContentExport.SessionEntryDto d : nullSafe(export.sessionEntries())) {
SessionEntryJpaEntity e = new SessionEntryJpaEntity();
e.setSessionId(IdRemapper.remapStringId(sessionMap, d.sessionId()));
e.setType(parseEntryType(d.type()));
e.setContent(d.content());
e.setOccurredAt(parseDateTime(d.occurredAt()));
sessionEntryRepo.save(e);
sessionEntryCount++;
}
result.count("sessionEntries", sessionEntryCount);
// -- PlaythroughFlag : playthroughId remappe (la contrainte unique (playthroughId,name)
// ne saute pas, le playthroughId etant neuf).
int flagCount = 0;
for (ContentExport.PlaythroughFlagDto d : nullSafe(export.playthroughFlags())) {
PlaythroughFlagJpaEntity e = new PlaythroughFlagJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
e.setName(d.name());
e.setValue(d.value());
playthroughFlagRepo.save(e);
flagCount++;
}
result.count("playthroughFlags", flagCount);
// -- Front (menaces regroupant des horloges) : playthroughId remappé.
for (ContentExport.FrontDto d : nullSafe(export.fronts())) {
FrontJpaEntity e = new FrontJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
e.setName(d.name());
e.setDescription(d.description());
e.setOrder(d.order());
frontMap.put(d.id(), frontRepo.save(e).getId());
}
result.count("fronts", frontMap.size());
// -- Clock (horloges de Partie) : playthroughId + frontId remappés ; triggerRef quête -> 2e passe.
for (ContentExport.ClockDto d : nullSafe(export.clocks())) {
ClockJpaEntity e = new ClockJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
e.setName(d.name());
e.setDescription(d.description());
e.setSegments(d.segments());
e.setFilled(d.filled());
e.setOrder(d.order());
e.setTriggerType(d.triggerType() != null ? d.triggerType() : ClockTrigger.NONE);
e.setTriggerRef(d.triggerRef());
e.setFrontId(IdRemapper.remapId(frontMap, d.frontId()));
clockMap.put(d.id(), clockRepo.save(e).getId());
}
result.count("clocks", clockMap.size());
// -- Arc (relatedPageIds remappe en 2e passe)
for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
ArcJpaEntity e = new ArcJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
e.setType(IdRemapper.parseArcType(d.type()));
e.setIcon(d.icon());
e.setThemes(d.themes());
e.setStakes(d.stakes());
e.setGmNotes(d.gmNotes());
e.setRewards(d.rewards());
e.setResolution(d.resolution());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
arcMap.put(d.id(), arcRepo.save(e).getId());
}
result.count("arcs", arcMap.size());
// -- ItemCatalog (+ items en cascade)
int catalogCount = 0, itemCount = 0;
for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) {
ItemCatalogJpaEntity e = new ItemCatalogJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setIcon(d.icon());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
List<CatalogItemJpaEntity> items = new ArrayList<>();
for (ContentExport.CatalogItemDto i : nullSafe(d.items())) {
CatalogItemJpaEntity item = new CatalogItemJpaEntity();
item.setName(i.name());
item.setPrice(i.price());
item.setCategory(i.category());
item.setDescription(i.description());
item.setPosition(i.position());
item.setCatalog(e); // lien parent requis pour la cascade
items.add(item);
itemCount++;
}
e.setItems(items);
itemCatalogRepo.save(e);
catalogCount++;
}
result.count("itemCatalogs", catalogCount);
result.count("catalogItems", itemCount);
// -- RandomTable (+ entries en cascade)
int tableCount = 0, entryCount = 0;
for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) {
RandomTableJpaEntity e = new RandomTableJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setDiceFormula(d.diceFormula());
e.setIcon(d.icon());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) {
RandomTableEntryJpaEntity entryE = new RandomTableEntryJpaEntity();
entryE.setMinRoll(en.minRoll());
entryE.setMaxRoll(en.maxRoll());
entryE.setLabel(en.label());
entryE.setDetail(en.detail());
entryE.setPosition(en.position());
entryE.setRandomTable(e);
entries.add(entryE);
entryCount++;
}
e.setEntries(entries);
randomTableRepo.save(e);
tableCount++;
}
result.count("randomTables", tableCount);
result.count("randomTableEntries", entryCount);
// -- Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de
// prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy.
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
ChapterJpaEntity e = new ChapterJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcId(IdRemapper.remapId(arcMap, d.arcId()));
e.setOrder(d.order());
e.setIcon(d.icon());
e.setGmNotes(d.gmNotes());
e.setPlayerObjectives(d.playerObjectives());
e.setNarrativeStakes(d.narrativeStakes());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
chapterMap.put(d.id(), chapterRepo.save(e).getId());
}
result.count("chapters", chapterMap.size());
boolean bundleHasQuests = export.quests() != null;
// -- Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ;
// prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt).
for (ContentExport.QuestDto d : nullSafe(export.quests())) {
QuestJpaEntity e = new QuestJpaEntity();
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setArcId(IdRemapper.remapId(arcMap, d.arcId())); // arcMap déjà prêt (arcs importés avant) ; null→null
e.setName(d.name());
e.setDescription(d.description());
e.setIcon(d.icon());
e.setOrder(d.order());
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappé 2e passe
e.setNodes(NODE_CONVERTER.convertToEntityAttribute(d.nodesJson())); // remappé 2e passe
e.setGmNotes(d.gmNotes());
e.setPlayerObjectives(d.playerObjectives());
e.setNarrativeStakes(d.narrativeStakes());
e.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe
e.setIllustrationImageIds(d.illustrationImageIds());
questMap.put(d.id(), questRepo.save(e).getId());
}
// -- Quest legacy (bundle SANS champ quests) : on convertit les chapitres qui jouaient le
// rôle de quête (arc HUB OU porteurs de prérequis OU référencés par une progression) en
// vraies Quests, pour ne pas perdre les quêtes d'un vieux backup. Ici questMap est clé
// par ANCIEN CHAPTER id (pas de collision : on compare chapter-id à chapter-id).
if (!bundleHasQuests) {
convertLegacyChaptersToQuests(export, campaignMap, arcMap, questMap);
}
result.count("quests", questMap.size());
// -- QuestProgression : playthroughId + (quest id) remappés (entités déjà insérées ;
// contrainte unique (playthroughId, questId) préservée car playthroughId neuf).
int questProgCount = 0;
for (ContentExport.QuestProgressionDto d : nullSafe(export.questProgressions())) {
QuestProgressionJpaEntity e = new QuestProgressionJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
Long oldRef = d.chapterId();
// v2 : oldRef est un quest id -> questMap. v1 : oldRef est un chapter id ; s'il a été
// converti en quête, questMap (clé chapter id) le résout, sinon fallback chapterMap.
Long newQuestId;
if (bundleHasQuests) {
newQuestId = IdRemapper.remapId(questMap, oldRef);
} else if (oldRef != null && questMap.containsKey(oldRef)) {
newQuestId = questMap.get(oldRef);
} else {
newQuestId = IdRemapper.remapId(chapterMap, oldRef);
}
e.setQuestId(newQuestId);
e.setStatus(parseProgressionStatus(d.status()));
questProgressionRepo.save(e);
questProgCount++;
}
result.count("questProgressions", questProgCount);
// -- Npc (relatedPageIds remappe en 2e passe)
for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
NpcJpaEntity e = new NpcJpaEntity();
e.setName(d.name());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setFolder(d.folder());
e.setOrder(d.order());
npcMap.put(d.id(), npcRepo.save(e).getId());
}
result.count("npcs", npcMap.size());
// -- Enemy
for (ContentExport.EnemyDto d : nullSafe(export.enemies())) {
EnemyJpaEntity e = new EnemyJpaEntity();
e.setName(d.name());
e.setLevel(d.level());
e.setFolder(d.folder());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setFoundryRef(d.foundryRef()); // ref externe Foundry : conservee telle quelle
e.setFoundryStats(d.foundryStats());
e.setOrder(d.order());
enemyMap.put(d.id(), enemyRepo.save(e).getId());
}
result.count("enemies", enemyMap.size());
// -- Character (playthroughId mis a null : hors perimetre)
for (ContentExport.CharacterDto d : nullSafe(export.characters())) {
CharacterJpaEntity e = new CharacterJpaEntity();
e.setName(d.name());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
// playthroughId remappe vers la Partie importee (ou null si le jeu n'etait pas
// dans l'export -> la map est vide). Evite une reference pendante.
e.setPlaythroughId(playthroughMap.get(d.playthroughId()));
e.setOrder(d.order());
characterMap.put(d.id(), characterRepo.save(e).getId());
}
result.count("characters", characterMap.size());
// -- Scene (enemyIds + relatedPageIds + branches remappes en 2e passe)
for (ContentExport.SceneDto d : nullSafe(export.scenes())) {
SceneJpaEntity e = new SceneJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId()));
e.setOrder(d.order());
e.setIcon(d.icon());
e.setType(d.type() != null ? d.type() : SceneType.GENERIC);
e.setLocation(d.location());
e.setTiming(d.timing());
e.setAtmosphere(d.atmosphere());
e.setPlayerNarration(d.playerNarration());
e.setGmSecretNotes(d.gmSecretNotes());
e.setChoicesConsequences(d.choicesConsequences());
e.setCombatDifficulty(d.combatDifficulty());
e.setEnemies(d.enemies());
e.setEnemyIds(d.enemyIds()); // remappe plus bas
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
// Battlemaps : ids StoredFile passes tels quels (meme logique que les refs
// d'images illustration, non remappees). Les exports anterieurs a V22
// portaient une paire unique -> reconstituee en premiere entree de liste.
List<SceneBattlemap> battlemaps = d.battlemaps();
if ((battlemaps == null || battlemaps.isEmpty())
&& (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) {
battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId()));
}
e.setBattlemaps(battlemaps != null ? battlemaps : List.of());
e.setGraphX(d.graphX());
e.setGraphY(d.graphY());
e.setBranches(d.branches()); // remappe plus bas
e.setRooms(d.rooms()); // Rooms: UUID, non remappes
sceneMap.put(d.id(), sceneRepo.save(e).getId());
}
result.count("scenes", sceneMap.size());
// 4. 2e PASSE de remapping.
// LoreNode.parentId
for (LoreNodeJpaEntity e : loreNodesToFix) {
Long newParent = loreNodeMap.get(e.getParentId());
if (newParent != null) {
e.setParentId(newParent);
loreNodeRepo.save(e);
}
}
// Template.defaultNodeId
for (ContentExport.TemplateDto d : templatesWithDefaultNode) {
Long newTemplateId = templateMap.get(d.id());
Long newNode = loreNodeMap.get(d.defaultNodeId());
if (newTemplateId != null && newNode != null) {
templateRepo.findById(newTemplateId).ifPresent(t -> {
t.setDefaultNodeId(newNode);
templateRepo.save(t);
});
}
}
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long).
for (Long newCampaignId : campaignMap.values()) {
campaignRepo.findById(newCampaignId).ifPresent(c -> {
String newLore = IdRemapper.remapStringId(loreMap, c.getLoreId());
String newGs = IdRemapper.remapStringId(gameSystemMap, c.getGameSystemId());
c.setLoreId(newLore);
c.setGameSystemId(newGs);
campaignRepo.save(c);
});
}
// Page.relatedPageIds
for (Long newPageId : pageMap.values()) {
pageRepo.findById(newPageId).ifPresent(p -> {
p.setRelatedPageIds(IdRemapper.remapStringList(pageMap, p.getRelatedPageIds()));
pageRepo.save(p);
});
}
// Arc.relatedPageIds
for (Long newArcId : arcMap.values()) {
arcRepo.findById(newArcId).ifPresent(a -> {
a.setRelatedPageIds(IdRemapper.remapStringList(pageMap, a.getRelatedPageIds()));
arcRepo.save(a);
});
}
// Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1)
for (Long newChapterId : chapterMap.values()) {
chapterRepo.findById(newChapterId).ifPresent(c -> {
c.setRelatedPageIds(IdRemapper.remapStringList(pageMap, c.getRelatedPageIds()));
chapterRepo.save(c);
});
}
// Npc.relatedPageIds
for (Long newNpcId : npcMap.values()) {
npcRepo.findById(newNpcId).ifPresent(n -> {
n.setRelatedPageIds(IdRemapper.remapStringList(pageMap, n.getRelatedPageIds()));
npcRepo.save(n);
});
}
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene)
for (Long newSceneId : sceneMap.values()) {
sceneRepo.findById(newSceneId).ifPresent(s -> {
s.setRelatedPageIds(IdRemapper.remapStringList(pageMap, s.getRelatedPageIds()));
s.setEnemyIds(IdRemapper.remapStringList(enemyMap, s.getEnemyIds()));
s.setBranches(IdRemapper.remapBranches(sceneMap, s.getBranches()));
sceneRepo.save(s);
});
}
// Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap),
// relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici.
for (Long newQuestId : questMap.values()) {
questRepo.findById(newQuestId).ifPresent(q -> {
q.setPrerequisites(IdRemapper.remapPrerequisites(questMap, q.getPrerequisites()));
q.setNodes(IdRemapper.remapQuestNodes(chapterMap, sceneMap, q.getNodes()));
q.setRelatedPageIds(IdRemapper.remapStringList(pageMap, q.getRelatedPageIds()));
questRepo.save(q);
});
}
// Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée
// (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper).
for (Long newClockId : clockMap.values()) {
clockRepo.findById(newClockId).ifPresent(c -> {
if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) {
c.setTriggerRef(IdRemapper.remapStringId(questMap, c.getTriggerRef()));
clockRepo.save(c);
}
});
}
return result.build(); return result.build();
} }
/**
* Conversion legacy : pour un bundle SANS champ {@code quests}, recrée des Quests à partir
* des chapitres qui jouaient le rôle de quête (arc HUB, OU porteurs de prérequis, OU
* référencés par une {@code quest_progression}). Alimente {@code questMap} (clé = ANCIEN
* chapter id du bundle -> nouvel id de quête). Les prérequis / nœuds / relatedPageIds sont
* remappés en 2e passe, comme pour les quêtes v2.
*/
private void convertLegacyChaptersToQuests(ContentExport export,
Map<Long, Long> campaignMap,
Map<Long, Long> arcMap,
Map<Long, Long> questMap) {
Map<Long, ContentExport.ArcDto> arcById = new HashMap<>();
for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a);
Set<Long> progressedChapterIds = new HashSet<>();
for (ContentExport.QuestProgressionDto qp : nullSafe(export.questProgressions())) {
if (qp.chapterId() != null) progressedChapterIds.add(qp.chapterId());
}
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
ContentExport.ArcDto arc = arcById.get(d.arcId());
boolean isHub = arc != null && "HUB".equals(arc.type());
List<Prerequisite> prereqs = PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson());
boolean hasPrereqs = prereqs != null && !prereqs.isEmpty();
boolean inProgression = progressedChapterIds.contains(d.id());
if (!isHub && !hasPrereqs && !inProgression) continue;
QuestJpaEntity q = new QuestJpaEntity();
q.setCampaignId(IdRemapper.remapId(campaignMap, arc != null ? arc.campaignId() : null));
// Quête legacy issue d'un arc HUB → rattachée à cet arc (préserve la structure HUB) ;
// les quêtes issues de prérequis/progression seules restent transverses.
q.setArcId(isHub ? IdRemapper.remapId(arcMap, d.arcId()) : null);
q.setName(d.name());
q.setDescription(d.description());
q.setIcon(d.icon());
q.setOrder(d.order());
q.setPrerequisites(prereqs); // remappé 2e passe (questMap)
q.setNodes(new ArrayList<>(List.of(
new QuestNodeRef(NodeType.CHAPTER, String.valueOf(d.id()), 0)))); // remappé 2e passe (chapterMap)
q.setGmNotes(d.gmNotes());
q.setPlayerObjectives(d.playerObjectives());
q.setNarrativeStakes(d.narrativeStakes());
q.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe (pageMap)
// illustrationImageIds : les chapitres legacy les conservent de leur côté.
questMap.put(d.id(), questRepo.save(q).getId());
}
}
// ----- Lecture de l'archive -----
/** Contenu déballé d'un zip d'import : {@code data.json} + binaires images + fichiers. */
private record ParsedArchive(ContentExport export,
Map<String, byte[]> imageBinaries,
Map<String, byte[]> fileBinaries) {
}
/**
* Déballe le zip : {@code data.json → ContentExport} et {@code images/<clé> → binaire}
* (le {@code manifest.json} est ignoré, info seulement). Lève si {@code data.json} manque.
*/
private ParsedArchive parseArchive(InputStream zipStream) {
ContentExport export = null;
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
Map<String, byte[]> fileBinaries = new LinkedHashMap<>(); // storageKey -> binaire
try (ZipInputStream zip = new ZipInputStream(zipStream)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if ("data.json".equals(name)) {
export = objectMapper.readValue(readAll(zip), ContentExport.class);
} else if (name.startsWith("images/") && !entry.isDirectory()) {
// La cle de stockage est le chemin sans le prefixe "images/" du zip,
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
String storageKey = name.substring("images/".length());
imageBinaries.put(storageKey, readAll(zip));
} else if (name.startsWith("files/") && !entry.isDirectory()) {
// Le prefixe zip "files/" enrobe le storageKey, lui-meme "files/UUID.ext" :
// on retire UNE fois le prefixe pour retrouver la cle d'origine.
String storageKey = name.substring("files/".length());
fileBinaries.put(storageKey, readAll(zip));
}
zip.closeEntry();
}
} catch (IOException e) {
throw new UncheckedIOException("Echec de lecture du zip d'import", e);
}
if (export == null) {
throw new IllegalArgumentException("Archive invalide : data.json introuvable");
}
return new ParsedArchive(export, imageBinaries, fileBinaries);
}
// ----- Helpers divers -----
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
/** Parse un horodatage ISO LocalDateTime, ou null si absent/illisible. */
private static java.time.LocalDateTime parseDateTime(String s) {
if (s == null || s.isBlank()) return null;
try {
return java.time.LocalDateTime.parse(s.trim());
} catch (java.time.format.DateTimeParseException e) {
return null;
}
}
/** Parse un EntryType, repli sur NOTE si inconnu/absent (jamais d'echec). */
private static EntryType parseEntryType(String s) {
if (s == null) return EntryType.NOTE;
try {
return EntryType.valueOf(s);
} catch (IllegalArgumentException e) {
return EntryType.NOTE;
}
}
/** Parse un ProgressionStatus, repli sur NOT_STARTED si inconnu/absent. */
private static ProgressionStatus parseProgressionStatus(String s) {
if (s == null) return ProgressionStatus.NOT_STARTED;
try {
return ProgressionStatus.valueOf(s);
} catch (IllegalArgumentException e) {
return ProgressionStatus.NOT_STARTED;
}
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
in.transferTo(buffer);
return buffer.toByteArray();
}
} }

View File

@@ -0,0 +1,84 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.QuestJpaEntity;
import com.loremind.infrastructure.persistence.jpa.QuestJpaRepository;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Conversion legacy d'un import (cf. {@link ImportService}) : pour un bundle SANS champ
* {@code quests}, recrée des Quests à partir des chapitres qui jouaient le rôle de quête
* (arc HUB, OU porteurs de prérequis, OU référencés par une {@code quest_progression}).
* Permet de ne pas perdre les quêtes d'un vieux backup.
*/
@Component
class LegacyQuestConverter {
// (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite
// est scellé, non sérialisable en polymorphe par l'ObjectMapper standard).
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
private final QuestJpaRepository questRepo;
LegacyQuestConverter(QuestJpaRepository questRepo) {
this.questRepo = questRepo;
}
/**
* Alimente {@code maps.questMap} (clé = ANCIEN chapter id du bundle -> nouvel id de
* quête). Les prérequis / nœuds / relatedPageIds sont remappés en 2e passe, comme
* pour les quêtes v2.
*/
void convertLegacyChaptersToQuests(ContentExport export, ImportIdMaps maps) {
Map<Long, ContentExport.ArcDto> arcById = new HashMap<>();
for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a);
Set<Long> progressedChapterIds = new HashSet<>();
for (ContentExport.QuestProgressionDto qp : nullSafe(export.questProgressions())) {
if (qp.chapterId() != null) progressedChapterIds.add(qp.chapterId());
}
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
ContentExport.ArcDto arc = arcById.get(d.arcId());
boolean isHub = arc != null && "HUB".equals(arc.type());
List<Prerequisite> prereqs = PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson());
boolean hasPrereqs = prereqs != null && !prereqs.isEmpty();
boolean inProgression = progressedChapterIds.contains(d.id());
if (!isHub && !hasPrereqs && !inProgression) continue;
QuestJpaEntity q = new QuestJpaEntity();
q.setCampaignId(IdRemapper.remapId(maps.campaignMap, arc != null ? arc.campaignId() : null));
// Quête legacy issue d'un arc HUB → rattachée à cet arc (préserve la structure HUB) ;
// les quêtes issues de prérequis/progression seules restent transverses.
q.setArcId(isHub ? IdRemapper.remapId(maps.arcMap, d.arcId()) : null);
q.setName(d.name());
q.setDescription(d.description());
q.setIcon(d.icon());
q.setOrder(d.order());
q.setPrerequisites(prereqs); // remappé 2e passe (questMap)
q.setNodes(new ArrayList<>(List.of(
new QuestNodeRef(NodeType.CHAPTER, String.valueOf(d.id()), 0)))); // remappé 2e passe (chapterMap)
q.setGmNotes(d.gmNotes());
q.setPlayerObjectives(d.playerObjectives());
q.setNarrativeStakes(d.narrativeStakes());
q.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe (pageMap)
// illustrationImageIds : les chapitres legacy les conservent de leur côté.
maps.questMap.put(d.id(), questRepo.save(q).getId());
}
}
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
}

View File

@@ -0,0 +1,122 @@
package com.loremind.infrastructure.transfer;
import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
import com.loremind.infrastructure.persistence.entity.LoreJpaEntity;
import com.loremind.infrastructure.persistence.entity.LoreNodeJpaEntity;
import com.loremind.infrastructure.persistence.entity.PageJpaEntity;
import com.loremind.infrastructure.persistence.entity.TemplateJpaEntity;
import com.loremind.infrastructure.persistence.jpa.GameSystemJpaRepository;
import com.loremind.infrastructure.persistence.jpa.LoreJpaRepository;
import com.loremind.infrastructure.persistence.jpa.LoreNodeJpaRepository;
import com.loremind.infrastructure.persistence.jpa.PageJpaRepository;
import com.loremind.infrastructure.persistence.jpa.TemplateJpaRepository;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 1re passe d'import du référentiel (cf. {@link ImportService}) : GameSystem, Lore,
* LoreNode, Template, Page. Alimente les maps de remapping correspondantes de
* {@link ImportIdMaps} ; les références résolues plus tard (parentId, defaultNodeId,
* relatedPageIds) sont notées pour la 2e passe.
*/
@Component
class LoreContentInserter {
private final GameSystemJpaRepository gameSystemRepo;
private final LoreJpaRepository loreRepo;
private final LoreNodeJpaRepository loreNodeRepo;
private final TemplateJpaRepository templateRepo;
private final PageJpaRepository pageRepo;
LoreContentInserter(GameSystemJpaRepository gameSystemRepo,
LoreJpaRepository loreRepo,
LoreNodeJpaRepository loreNodeRepo,
TemplateJpaRepository templateRepo,
PageJpaRepository pageRepo) {
this.gameSystemRepo = gameSystemRepo;
this.loreRepo = loreRepo;
this.loreNodeRepo = loreNodeRepo;
this.templateRepo = templateRepo;
this.pageRepo = pageRepo;
}
void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
// -- GameSystem
for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) {
GameSystemJpaEntity e = new GameSystemJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setRulesMarkdown(d.rulesMarkdown());
e.setCharacterTemplate(d.characterTemplate());
e.setNpcTemplate(d.npcTemplate());
e.setEnemyTemplate(d.enemyTemplate());
e.setFoundryActorType(d.foundryActorType());
e.setAuthor(d.author());
e.setPublic(d.isPublic());
maps.gameSystemMap.put(d.id(), gameSystemRepo.save(e).getId());
}
result.count("gameSystems", maps.gameSystemMap.size());
// -- Lore
for (ContentExport.LoreDto d : nullSafe(export.lores())) {
LoreJpaEntity e = new LoreJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setNodeCount(d.nodeCount());
e.setPageCount(d.pageCount());
maps.loreMap.put(d.id(), loreRepo.save(e).getId());
}
result.count("lores", maps.loreMap.size());
// -- LoreNode (parentId remappe en 2e passe)
for (ContentExport.LoreNodeDto d : nullSafe(export.loreNodes())) {
LoreNodeJpaEntity e = new LoreNodeJpaEntity();
e.setName(d.name());
e.setIcon(d.icon());
e.setParentId(d.parentId()); // remappe en 2e passe
e.setLoreId(IdRemapper.remapId(maps.loreMap, d.loreId()));
LoreNodeJpaEntity saved = loreNodeRepo.save(e);
maps.loreNodeMap.put(d.id(), saved.getId());
if (d.parentId() != null) maps.loreNodesToFix.add(saved);
}
result.count("loreNodes", maps.loreNodeMap.size());
// -- Template (defaultNodeId remappe en 2e passe)
for (ContentExport.TemplateDto d : nullSafe(export.templates())) {
TemplateJpaEntity e = new TemplateJpaEntity();
e.setLoreId(IdRemapper.remapId(maps.loreMap, d.loreId()));
e.setName(d.name());
e.setDescription(d.description());
e.setDefaultNodeId(d.defaultNodeId()); // remappe en 2e passe
e.setFields(d.fields());
maps.templateMap.put(d.id(), templateRepo.save(e).getId());
if (d.defaultNodeId() != null) maps.templatesWithDefaultNode.add(d);
}
result.count("templates", maps.templateMap.size());
// -- Page (relatedPageIds remappe en 2e passe)
for (ContentExport.PageDto d : nullSafe(export.pages())) {
PageJpaEntity e = new PageJpaEntity();
e.setLoreId(IdRemapper.remapId(maps.loreMap, d.loreId()));
e.setNodeId(IdRemapper.remapId(maps.loreNodeMap, d.nodeId()));
e.setTemplateId(IdRemapper.remapId(maps.templateMap, d.templateId()));
e.setTitle(d.title());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setImageFraming(d.imageFraming());
e.setKeyValueValues(d.keyValueValues());
e.setTableValues(d.tableValues());
e.setNotes(d.notes());
e.setTags(d.tags());
e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe
maps.pageMap.put(d.id(), pageRepo.save(e).getId());
}
result.count("pages", maps.pageMap.size());
}
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
}

View File

@@ -0,0 +1,217 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.playcontext.ClockTrigger;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
import com.loremind.infrastructure.persistence.entity.ClockJpaEntity;
import com.loremind.infrastructure.persistence.entity.FrontJpaEntity;
import com.loremind.infrastructure.persistence.entity.PlaythroughFlagJpaEntity;
import com.loremind.infrastructure.persistence.entity.PlaythroughJpaEntity;
import com.loremind.infrastructure.persistence.entity.QuestProgressionJpaEntity;
import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity;
import com.loremind.infrastructure.persistence.entity.SessionJpaEntity;
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
import com.loremind.infrastructure.persistence.jpa.ClockJpaRepository;
import com.loremind.infrastructure.persistence.jpa.FrontJpaRepository;
import com.loremind.infrastructure.persistence.jpa.PlaythroughFlagJpaRepository;
import com.loremind.infrastructure.persistence.jpa.PlaythroughJpaRepository;
import com.loremind.infrastructure.persistence.jpa.QuestProgressionJpaRepository;
import com.loremind.infrastructure.persistence.jpa.SessionEntryJpaRepository;
import com.loremind.infrastructure.persistence.jpa.SessionJpaRepository;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 1re passe d'import de l'état de jeu (cf. {@link ImportService}) : Playthrough,
* Session, SessionEntry, PlaythroughFlag, Front, Clock, QuestProgression, Character.
* Passe APRÈS {@link CampaignContentInserter} : les progressions référencent les
* quêtes/chapitres importés, et les personnages la Partie importée.
*/
@Component
class PlayStateInserter {
private final PlaythroughJpaRepository playthroughRepo;
private final SessionJpaRepository sessionRepo;
private final SessionEntryJpaRepository sessionEntryRepo;
private final PlaythroughFlagJpaRepository playthroughFlagRepo;
private final FrontJpaRepository frontRepo;
private final ClockJpaRepository clockRepo;
private final QuestProgressionJpaRepository questProgressionRepo;
private final CharacterJpaRepository characterRepo;
PlayStateInserter(PlaythroughJpaRepository playthroughRepo,
SessionJpaRepository sessionRepo,
SessionEntryJpaRepository sessionEntryRepo,
PlaythroughFlagJpaRepository playthroughFlagRepo,
FrontJpaRepository frontRepo,
ClockJpaRepository clockRepo,
QuestProgressionJpaRepository questProgressionRepo,
CharacterJpaRepository characterRepo) {
this.playthroughRepo = playthroughRepo;
this.sessionRepo = sessionRepo;
this.sessionEntryRepo = sessionEntryRepo;
this.playthroughFlagRepo = playthroughFlagRepo;
this.frontRepo = frontRepo;
this.clockRepo = clockRepo;
this.questProgressionRepo = questProgressionRepo;
this.characterRepo = characterRepo;
}
void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
// -- Playthrough (Partie) : campaignId remappe
for (ContentExport.PlaythroughDto d : nullSafe(export.playthroughs())) {
PlaythroughJpaEntity e = new PlaythroughJpaEntity();
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
e.setName(d.name());
e.setDescription(d.description());
maps.playthroughMap.put(d.id(), playthroughRepo.save(e).getId());
}
result.count("playthroughs", maps.playthroughMap.size());
// -- Session : campaignId (ref faible String) + playthroughId remappes
for (ContentExport.SessionDto d : nullSafe(export.sessions())) {
SessionJpaEntity e = new SessionJpaEntity();
e.setName(d.name());
e.setCampaignId(IdRemapper.remapStringId(maps.campaignMap, d.campaignId()));
e.setPlaythroughId(IdRemapper.remapId(maps.playthroughMap, d.playthroughId()));
e.setStartedAt(parseDateTime(d.startedAt()));
e.setEndedAt(parseDateTime(d.endedAt()));
maps.sessionMap.put(d.id(), sessionRepo.save(e).getId());
}
result.count("sessions", maps.sessionMap.size());
// -- SessionEntry : sessionId (ref faible String) remappe
int sessionEntryCount = 0;
for (ContentExport.SessionEntryDto d : nullSafe(export.sessionEntries())) {
SessionEntryJpaEntity e = new SessionEntryJpaEntity();
e.setSessionId(IdRemapper.remapStringId(maps.sessionMap, d.sessionId()));
e.setType(parseEntryType(d.type()));
e.setContent(d.content());
e.setOccurredAt(parseDateTime(d.occurredAt()));
sessionEntryRepo.save(e);
sessionEntryCount++;
}
result.count("sessionEntries", sessionEntryCount);
// -- PlaythroughFlag : playthroughId remappe (la contrainte unique (playthroughId,name)
// ne saute pas, le playthroughId etant neuf).
int flagCount = 0;
for (ContentExport.PlaythroughFlagDto d : nullSafe(export.playthroughFlags())) {
PlaythroughFlagJpaEntity e = new PlaythroughFlagJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(maps.playthroughMap, d.playthroughId()));
e.setName(d.name());
e.setValue(d.value());
playthroughFlagRepo.save(e);
flagCount++;
}
result.count("playthroughFlags", flagCount);
// -- Front (menaces regroupant des horloges) : playthroughId remappé.
for (ContentExport.FrontDto d : nullSafe(export.fronts())) {
FrontJpaEntity e = new FrontJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(maps.playthroughMap, d.playthroughId()));
e.setName(d.name());
e.setDescription(d.description());
e.setOrder(d.order());
maps.frontMap.put(d.id(), frontRepo.save(e).getId());
}
result.count("fronts", maps.frontMap.size());
// -- Clock (horloges de Partie) : playthroughId + frontId remappés ; triggerRef quête -> 2e passe.
for (ContentExport.ClockDto d : nullSafe(export.clocks())) {
ClockJpaEntity e = new ClockJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(maps.playthroughMap, d.playthroughId()));
e.setName(d.name());
e.setDescription(d.description());
e.setSegments(d.segments());
e.setFilled(d.filled());
e.setOrder(d.order());
e.setTriggerType(d.triggerType() != null ? d.triggerType() : ClockTrigger.NONE);
e.setTriggerRef(d.triggerRef());
e.setFrontId(IdRemapper.remapId(maps.frontMap, d.frontId()));
maps.clockMap.put(d.id(), clockRepo.save(e).getId());
}
result.count("clocks", maps.clockMap.size());
// -- QuestProgression : playthroughId + (quest id) remappés (quêtes/chapitres déjà
// insérés ; contrainte unique (playthroughId, questId) préservée car playthroughId neuf).
boolean bundleHasQuests = export.quests() != null;
int questProgCount = 0;
for (ContentExport.QuestProgressionDto d : nullSafe(export.questProgressions())) {
QuestProgressionJpaEntity e = new QuestProgressionJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(maps.playthroughMap, d.playthroughId()));
Long oldRef = d.chapterId();
// v2 : oldRef est un quest id -> questMap. v1 : oldRef est un chapter id ; s'il a été
// converti en quête, questMap (clé chapter id) le résout, sinon fallback chapterMap.
Long newQuestId;
if (bundleHasQuests) {
newQuestId = IdRemapper.remapId(maps.questMap, oldRef);
} else if (oldRef != null && maps.questMap.containsKey(oldRef)) {
newQuestId = maps.questMap.get(oldRef);
} else {
newQuestId = IdRemapper.remapId(maps.chapterMap, oldRef);
}
e.setQuestId(newQuestId);
e.setStatus(parseProgressionStatus(d.status()));
questProgressionRepo.save(e);
questProgCount++;
}
result.count("questProgressions", questProgCount);
// -- Character : inséré ici (et non avec le contenu de campagne) car il référence la
// Partie importée via playthroughId.
for (ContentExport.CharacterDto d : nullSafe(export.characters())) {
CharacterJpaEntity e = new CharacterJpaEntity();
e.setName(d.name());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
// playthroughId remappe vers la Partie importee (ou null si le jeu n'etait pas
// dans l'export -> la map est vide). Evite une reference pendante.
e.setPlaythroughId(maps.playthroughMap.get(d.playthroughId()));
e.setOrder(d.order());
maps.characterMap.put(d.id(), characterRepo.save(e).getId());
}
result.count("characters", maps.characterMap.size());
}
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
/** Parse un horodatage ISO LocalDateTime, ou null si absent/illisible. */
private static java.time.LocalDateTime parseDateTime(String s) {
if (s == null || s.isBlank()) return null;
try {
return java.time.LocalDateTime.parse(s.trim());
} catch (java.time.format.DateTimeParseException e) {
return null;
}
}
/** Parse un EntryType, repli sur NOTE si inconnu/absent (jamais d'echec). */
private static EntryType parseEntryType(String s) {
if (s == null) return EntryType.NOTE;
try {
return EntryType.valueOf(s);
} catch (IllegalArgumentException e) {
return EntryType.NOTE;
}
}
/** Parse un ProgressionStatus, repli sur NOT_STARTED si inconnu/absent. */
private static ProgressionStatus parseProgressionStatus(String s) {
if (s == null) return ProgressionStatus.NOT_STARTED;
try {
return ProgressionStatus.valueOf(s);
} catch (IllegalArgumentException e) {
return ProgressionStatus.NOT_STARTED;
}
}
}

View File

@@ -60,6 +60,19 @@ public class CampaignController {
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
} }
/** Corps de la sauvegarde des positions du graphe ({@code {"positions": "<json>"}}). */
public record GraphPositionsBody(String positions) {}
/**
* Sauvegarde la disposition personnalisée du graphe de campagne (drag et drop).
* JSON opaque côté back : c'est un état de présentation possédé par le front.
*/
@PutMapping("/{id}/graph-positions")
public ResponseEntity<Void> updateGraphPositions(@PathVariable String id, @RequestBody GraphPositionsBody body) {
campaignService.updateGraphPositions(id, body.positions());
return ResponseEntity.noContent().build();
}
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) { public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
Campaign updatedCampaign = campaignService.updateCampaign( Campaign updatedCampaign = campaignService.updateCampaign(

View File

@@ -52,15 +52,6 @@ public class NpcController {
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
} }
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
@GetMapping("/lore/{loreId}")
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {
List<NpcDTO> dtos = npcService.getNpcsByLoreId(loreId).stream()
.map(npcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) { public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder())); Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));

View File

@@ -19,4 +19,6 @@ public class CampaignDTO {
private String loreId; private String loreId;
/** Nullable : campagne sans système de JDR associé (générique). */ /** Nullable : campagne sans système de JDR associé (générique). */
private String gameSystemId; private String gameSystemId;
/** Positions des nœuds du graphe de campagne (JSON opaque, état de présentation). */
private String graphPositions;
} }

View File

@@ -23,6 +23,7 @@ public class CampaignMapper {
dto.setPlayerCount(campaign.getPlayerCount()); dto.setPlayerCount(campaign.getPlayerCount());
dto.setLoreId(campaign.getLoreId()); dto.setLoreId(campaign.getLoreId());
dto.setGameSystemId(campaign.getGameSystemId()); dto.setGameSystemId(campaign.getGameSystemId());
dto.setGraphPositions(campaign.getGraphPositions());
return dto; return dto;
} }

View File

@@ -0,0 +1,4 @@
-- Positions personnalisées des nœuds du graphe de campagne (drag & drop).
-- JSON opaque côté back ("<kind>:<id>" -> {x, y}) : pur état de présentation,
-- possédé par le front. Nullable = disposition automatique (force layout).
ALTER TABLE campaigns ADD COLUMN graph_positions TEXT;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,187 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.
TeX Gyre DJV Math
-----------------
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
(on behalf of TeX users groups) are in public domain.
Letters imported from Euler Fraktur from AMSfonts are (c) American
Mathematical Society (see below).
Bitstream Vera Fonts Copyright
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license (“Fonts”) and associated
documentation
files (the “Font Software”), to reproduce and distribute the Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute,
and/or sell copies of the Font Software, and to permit persons to whom
the Font Software is furnished to do so, subject to the following
conditions:
The above copyright and trademark notices and this permission notice
shall be
included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional
glyphs or characters may be added to the Fonts, only if the fonts are
renamed
to names not containing either the words “Bitstream” or the word “Vera”.
This License becomes null and void to the extent applicable to Fonts or
Font Software
that has been modified and is distributed under the “Bitstream Vera”
names.
The Font Software may be sold as part of a larger software package but
no copy
of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
INABILITY TO USE
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of GNOME, the GNOME
Foundation,
and Bitstream Inc., shall not be used in advertising or otherwise to promote
the sale, use or other dealings in this Font Software without prior written
authorization from the GNOME Foundation or Bitstream Inc., respectively.
For further information, contact: fonts at gnome dot org.
AMSFonts (v. 2.2) copyright
The PostScript Type 1 implementation of the AMSFonts produced by and
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
available for general use. This has been accomplished through the
cooperation
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
Members of this consortium include:
Elsevier Science IBM Corporation Society for Industrial and Applied
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
In order to assure the authenticity of these fonts, copyright will be
held by
the American Mathematical Society. This is not meant to restrict in any way
the legitimate use of the fonts, such as (but not limited to) electronic
distribution of documents containing these fonts, inclusion of these fonts
into other public domain or commercial font collections or computer
applications, use of the outline data to create derivative fonts and/or
faces, etc. However, the AMS does require that the AMS copyright notice be
removed from any derivative versions of the fonts which have been altered in
any way. In addition, to ensure the fidelity of TeX documents using Computer
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
has requested that any alterations which yield different font metrics be
given a different name.
$Id$

View File

@@ -364,4 +364,45 @@ public class CampaignServiceTest {
assertEquals(1, result.size()); assertEquals(1, result.size());
verify(campaignRepository, times(1)).searchByName("Test"); verify(campaignRepository, times(1)).searchByName("Test");
} }
@Test
void testUpdateGraphPositions_SavesJson() {
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
when(campaignRepository.save(any(Campaign.class))).thenAnswer(inv -> inv.getArgument(0));
campaignService.updateGraphPositions("campaign-1", "{\"page:1\":{\"x\":10,\"y\":20}}");
assertEquals("{\"page:1\":{\"x\":10,\"y\":20}}", testCampaign.getGraphPositions());
verify(campaignRepository, times(1)).save(testCampaign);
}
@Test
void testUpdateGraphPositions_BlankResetsToNull() {
// '' = retour à la disposition automatique (le front envoie une chaîne vide).
testCampaign.setGraphPositions("{\"page:1\":{\"x\":1,\"y\":2}}");
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
when(campaignRepository.save(any(Campaign.class))).thenAnswer(inv -> inv.getArgument(0));
campaignService.updateGraphPositions("campaign-1", " ");
assertNull(testCampaign.getGraphPositions());
}
@Test
void testUpdateGraphPositions_RejectsOversizedPayload() {
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
String huge = "x".repeat(200_001);
assertThrows(IllegalArgumentException.class,
() -> campaignService.updateGraphPositions("campaign-1", huge));
verify(campaignRepository, never()).save(any(Campaign.class));
}
@Test
void testUpdateGraphPositions_UnknownCampaign() {
when(campaignRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> campaignService.updateGraphPositions("missing", "{}"));
}
} }

View File

@@ -0,0 +1,405 @@
package com.loremind.infrastructure.transfer.pdf;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.LinkType;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.RoomBranch;
import com.loremind.domain.campaigncontext.SceneBattlemap;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Genere un livret PDF de DEMONSTRATION avec des donnees riches et representatives
* (arcs, chapitres, scenes, quetes, PNJ, bestiaire, lore, images placeholder) SANS
* Spring ni base : tout est mocke Mockito. Le PDF est ecrit dans
* {@code target/campaign-preview.pdf} pour inspection visuelle de la mise en page.
*/
class PdfExportPreviewTest {
private static final String LOREM =
"Le vent du nord charrie des cendres depuis trois jours. Les habitants de Valfroide "
+ "murmurent que la Tour de Jais s'est rallumée, et que son maître, qu'on croyait mort "
+ "à la bataille des Gués, aurait passé un pacte avec les Profondeurs.\n"
+ "Les héros arrivent alors que la foire d'automne bat son plein : un contraste saisissant "
+ "entre les lampions colorés et la peur qui se lit sur les visages dès qu'on prononce "
+ "le nom de la tour.";
private static final String LONG_NOTES =
"Points importants à garder en tête :\n"
+ "- Maëlis ment sur son passé (elle était scribe de la Tour).\n"
+ "- Le médaillon des PJ réagit à proximité des sceaux — décrire une chaleur diffuse.\n"
+ "- Si les joueurs interrogent le forgeron, il oriente vers la crypte SANS parler du pacte.\n"
+ "Ne pas révéler le vrai nom du Maître de Jais avant l'acte III ; toute divination "
+ "renvoie une image brouillée et un goût de cendre.";
@Test
void dumpPreviewPdf() throws Exception {
var campaignRepo = mock(CampaignJpaRepository.class);
var arcRepo = mock(ArcJpaRepository.class);
var chapterRepo = mock(ChapterJpaRepository.class);
var sceneRepo = mock(SceneJpaRepository.class);
var questRepo = mock(QuestJpaRepository.class);
var npcRepo = mock(NpcJpaRepository.class);
var enemyRepo = mock(EnemyJpaRepository.class);
var randomTableRepo = mock(RandomTableJpaRepository.class);
var gameSystemRepo = mock(GameSystemJpaRepository.class);
var imageRepo = mock(ImageJpaRepository.class);
var storedFileRepo = mock(StoredFileJpaRepository.class);
var loreNodeRepo = mock(LoreNodeJpaRepository.class);
var pageRepo = mock(PageJpaRepository.class);
var templateRepo = mock(TemplateJpaRepository.class);
var imageStorage = mock(ImageStorage.class);
var fileStorage = mock(FileStorage.class);
// --- Campagne -------------------------------------------------------
when(campaignRepo.findById(1L)).thenReturn(Optional.of(CampaignJpaEntity.builder()
.id(1L).name("Les Cendres de la Tour de Jais")
.description("Une campagne d'enquête et d'exploration pour 4 à 5 joueurs, du niveau 3 au niveau 8.\n"
+ "Les héros devront démêler les fils d'un pacte ancien avant que la Tour ne s'éveille tout à fait.")
.gameSystemId("10").loreId("20").arcsCount(2).build()));
// --- Structure narrative : 2 arcs -> chapitres -> scenes -------------
when(arcRepo.findByCampaignId(1L)).thenReturn(List.of(
ArcJpaEntity.builder().id(100L).campaignId(1L).order(0).name("Acte I — Les braises de Valfroide")
.description(LOREM)
.themes("Enquête, faux-semblants, communauté sous tension.")
.stakes("Découvrir qui ravive les sceaux de la Tour avant la nuit des Cendres.")
.rewards("Le médaillon du Guet (500 po), la confiance du conseil de Valfroide.")
.resolution("L'acte se clôt quand les PJ identifient le rôle de Maëlis, quelle que soit leur décision à son égard.")
.gmNotes(LONG_NOTES)
.illustrationImageIds(List.of("400"))
.build(),
ArcJpaEntity.builder().id(101L).campaignId(1L).order(1).name("Acte II — Sous les Gués")
.description("Les indices convergent vers les ruines noyées de l'ancien champ de bataille.")
.stakes("Récupérer le troisième sceau avant les émissaires des Profondeurs.")
.build(),
// Arc technique SYSTEM (« Quêtes libres ») : conteneurs des quêtes hors arc.
// Doit etre MASQUE de la narration du livret (comme dans l'arbre UI).
ArcJpaEntity.builder().id(199L).campaignId(1L).order(9999).name("Quêtes libres")
.type(ArcType.SYSTEM).build()));
when(chapterRepo.findByArcId(100L)).thenReturn(List.of(
ChapterJpaEntity.builder().id(200L).arcId(100L).order(0).name("La foire d'automne")
.description("Arrivée à Valfroide en pleine foire ; premiers contacts, premières fausses notes.")
.playerObjectives("Rencontrer le conseil ; retrouver la trace du colporteur disparu.")
.narrativeStakes("Gagner (ou perdre) la confiance des habitants — cela pèsera sur tout l'acte.")
.gmNotes("Jouer la foire comme un moment léger : le contraste rendra la suite plus sombre.")
.build(),
ChapterJpaEntity.builder().id(201L).arcId(100L).order(1).name("La crypte du Guet")
.description("Sous la chapelle, la crypte scellée que le forgeron refuse d'évoquer.")
.playerObjectives("Ouvrir la crypte ; comprendre la nature des sceaux.")
.build()));
when(chapterRepo.findByArcId(101L)).thenReturn(List.of(
ChapterJpaEntity.builder().id(202L).arcId(101L).order(0).name("Les ruines noyées")
.description("Exploration des Gués engloutis, entre brume et souvenirs de bataille.")
.build(),
// Chapitre-conteneur JUMEAU de la quête de hub « Les trois sceaux » (même nom,
// description vide) : le livret doit le rendre comme « Quête » avec les champs
// de la quête fusionnés — et NE PAS répéter la quête dans la partie « Quêtes ».
ChapterJpaEntity.builder().id(203L).arcId(101L).order(1).name("Les trois sceaux")
.description("").build()));
// Conteneur d'une quête LIBRE, hébergé dans l'arc SYSTEM.
when(chapterRepo.findByArcId(199L)).thenReturn(List.of(
ChapterJpaEntity.builder().id(250L).arcId(199L).order(0)
.name("Chasse au trésor du vieux moulin").description("").build()));
when(sceneRepo.findByChapterId(200L)).thenReturn(List.of(
SceneJpaEntity.builder().id(300L).chapterId(200L).order(0).name("L'auberge du Chaudron Fêlé")
.location("Grande salle de l'auberge, Valfroide.")
.timing("Soirée du premier jour, pendant la foire.")
.atmosphere("Chaleureuse en surface : feux, musique, odeur de cidre chaud. Mais les conversations s'arrêtent quand on parle de la Tour.")
.playerNarration("La porte s'ouvre sur une vague de chaleur et de rires. Une serveuse vous fait signe : « Installez-vous, on ne refuse personne la semaine de la foire ! »")
.gmSecretNotes("Maëlis observe les PJ depuis l'alcôve nord. Si un PJ la remarque (Perception DD 15), elle lève son verre sans se cacher.")
.choicesConsequences("Si les PJ paient une tournée : +1 aux tests sociaux à Valfroide.\nS'ils mentionnent la Tour à voix haute : la salle se vide en dix minutes.")
.branches(List.of(
SceneBranch.of("Enquêter au stand dès le matin", "301"),
new SceneBranch("Suivre la silhouette aperçue dans l'alcôve", "302",
"si un PJ a repéré Maëlis", LinkType.CLUE)))
.build(),
SceneJpaEntity.builder().id(301L).chapterId(200L).order(1).name("Le stand du colporteur")
.location("Allée marchande, place du Guet.")
.atmosphere("Le stand est intact mais abandonné ; la marchandise n'a pas été pillée, ce qui inquiète plus que tout.")
.gmSecretNotes("Sous le comptoir : un carnet dont trois pages ont été arrachées, imprégnées d'odeur de cendre.")
.build(),
SceneJpaEntity.builder().id(302L).chapterId(200L).order(2).name("Embuscade des masques de suie")
.location("Ruelle derrière la halle aux grains.")
.timing("Nuit, après la fermeture de la foire.")
.playerNarration("Trois silhouettes se détachent des ombres, visages couverts de suie. Celle du centre tient votre carnet volé.")
.combatDifficulty("Rencontre moyenne : 3 sbires (masques de suie) + 1 sergent. Les sbires fuient si le sergent tombe.")
.enemyIds(List.of("850"))
.enemies("Le sergent : profil du sbire avec +5 PV et une rapière (+1 aux dégâts).")
.battlemaps(List.of(new SceneBattlemap("Nuit", "500", null),
new SceneBattlemap("Jour", "501", null)))
.build()));
when(sceneRepo.findByChapterId(201L)).thenReturn(List.of(
SceneJpaEntity.builder().id(303L).chapterId(201L).order(0).name("Le seuil scellé")
.location("Crypte sous la chapelle du Guet.")
.atmosphere("Froid minéral ; les torches brûlent bleu à l'approche du sceau.")
.gmSecretNotes("Le sceau se brise si on prononce le nom du colporteur — indice dans son carnet.")
// Scene explorable : deux pieces reliees (exerce le rendu des Rooms).
.rooms(List.of(
Room.builder().id("r1").name("Antichambre du Guet").order(0)
.description("Bancs de pierre renversés ; des traces de suie récentes mènent au mur nord.")
.traps("Dalle piégée devant la porte nord (DD 13, dard empoisonné).")
.branches(List.of(RoomBranch.of("Porte nord descellée", "r2")))
.build(),
Room.builder().id("r2").name("Salle du premier sceau").order(1).floor(-1)
.description("Une rotonde basse ; le sceau pulse d'une lueur froide au centre.")
.enemyIds(List.of("851"))
.loot("Le fragment du premier sceau (objet de quête) ; 120 po en offrandes anciennes.")
.gmNotes("Si le sceau est brisé ICI, la Sentinelle se reforme chaque nuit.")
.branches(List.of(new RoomBranch("Remontée par l'éboulis", "r1",
"si les PJ ont déclenché l'alarme")))
.build()))
.build()));
when(sceneRepo.findByChapterId(202L)).thenReturn(List.of(
SceneJpaEntity.builder().id(304L).chapterId(202L).order(0).name("La brume des Gués")
.location("Marais des Gués, ancien champ de bataille.")
.atmosphere("Brume à hauteur de poitrine ; les sons portent mal, les distances trompent.")
.build()));
when(sceneRepo.findByChapterId(203L)).thenReturn(List.of(
SceneJpaEntity.builder().id(305L).chapterId(203L).order(0).name("Le premier sceau")
.location("Salle des archives englouties.")
.gmSecretNotes("Le sceau porte le sceau personnel de Maëlis — indice majeur.")
.build()));
when(sceneRepo.findByChapterId(250L)).thenReturn(List.of(
SceneJpaEntity.builder().id(350L).chapterId(250L).order(0).name("Le moulin abandonné")
.location("Vieux moulin, une lieue au nord de Valfroide.")
.atmosphere("Poussière de farine, toiles d'araignée, et un coffre trop propre pour le décor.")
.build()));
// --- Quêtes -----------------------------------------------------------
when(questRepo.findByCampaignId(1L)).thenReturn(List.of(
QuestJpaEntity.builder().id(900L).campaignId(1L).order(0).name("Retrouver le colporteur")
.description("Bertoul le colporteur a disparu la veille de la foire en laissant tout son stock.")
.playerObjectives("Suivre sa trace ; récupérer son carnet ; découvrir ce qu'il a vu à la Tour.")
.narrativeStakes("Bertoul est le seul témoin vivant du rallumage des sceaux.")
.gmNotes("S'il est sauvé, Bertoul devient un allié récurrent (et une cible).")
.nodes(List.of(new QuestNodeRef(NodeType.SCENE, "301", 0),
new QuestNodeRef(NodeType.SCENE, "302", 1),
new QuestNodeRef(NodeType.CHAPTER, "201", 2)))
.build(),
// Quête de HUB (arcId=101, conteneur jumeau 203) : fusionnée dans la narration.
QuestJpaEntity.builder().id(901L).campaignId(1L).order(1).name("Les trois sceaux")
.arcId(101L)
.description("Trois sceaux maintiennent la Tour endormie ; deux faiblissent déjà.")
.prerequisites(List.of(new Prerequisite.QuestCompleted("900"),
new Prerequisite.SessionReached(3),
new Prerequisite.FlagSet("crypte_ouverte")))
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "203", 0),
new QuestNodeRef(NodeType.SCENE, "304", 1)))
.playerObjectives("Localiser et renforcer (ou briser) les trois sceaux.")
.build(),
// Quête LIBRE (conteneur 250 en arc SYSTEM) : rendue dans la partie « Quêtes »
// AVEC les scènes de son conteneur (sinon elles disparaîtraient du livret).
QuestJpaEntity.builder().id(902L).campaignId(1L).order(2).name("Chasse au trésor du vieux moulin")
.description("Une carte au trésor achetée à la foire mène au vieux moulin. Trop beau pour être honnête.")
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "250", 0)))
.playerObjectives("Suivre la carte ; découvrir qui l'a mise en circulation.")
.gmNotes("La carte est un appât des masques de suie pour jauger les PJ.")
.build()));
// --- Systeme de jeu : templates PNJ / ennemis ------------------------
when(gameSystemRepo.findById(10L)).thenReturn(Optional.of(GameSystemJpaEntity.builder()
.id(10L).name("D&D 5e (maison)")
.npcTemplate(List.of(
TemplateField.text("Apparence"),
TemplateField.text("Personnalité"),
TemplateField.text("Objectifs"),
TemplateField.text("Secrets"),
TemplateField.keyValueList("Caractéristiques", List.of("FOR", "DEX", "CON", "INT", "SAG", "CHA"))))
.enemyTemplate(List.of(
TemplateField.text("Description"),
TemplateField.text("Tactique"),
TemplateField.keyValueList("Caractéristiques", List.of("FOR", "DEX", "CON", "INT", "SAG", "CHA")),
TemplateField.table("Attaques", List.of("Nom", "Bonus", "Dégâts", "Portée"))))
.build()));
// --- PNJ --------------------------------------------------------------
when(npcRepo.findByCampaignIdOrderByOrderAsc(1L)).thenReturn(List.of(
NpcJpaEntity.builder().id(800L).campaignId(1L).order(0).name("Maëlis la Discrète")
.folder("Valfroide").portraitImageId("410")
.values(Map.of(
"Apparence", "Quarantaine élégante, cheveux gris coupés court, toujours une écharpe couleur cendre.",
"Personnalité", "Posée, observatrice, répond aux questions par des questions.",
"Objectifs", "Empêcher le réveil de la Tour sans révéler son propre rôle passé.",
"Secrets", "Ancienne scribe de la Tour de Jais ; c'est elle qui a rédigé le pacte."))
.keyValueValues(Map.of("Caractéristiques", Map.of(
"FOR", "9", "DEX", "12", "CON", "10", "INT", "17", "SAG", "15", "CHA", "14")))
.build(),
NpcJpaEntity.builder().id(801L).campaignId(1L).order(1).name("Bertoul le colporteur")
.folder("Valfroide").portraitImageId("411")
.values(Map.of(
"Apparence", "Petit homme rond au sourire édenté, mains toujours en mouvement.",
"Personnalité", "Bavard, superstitieux, incapable de garder un secret plus d'une journée.",
"Objectifs", "Survivre. Éventuellement vendre quelque chose au passage."))
.build(),
NpcJpaEntity.builder().id(802L).campaignId(1L).order(0).name("Le Maître de Jais")
.folder("Antagonistes")
.values(Map.of(
"Apparence", "Nul ne l'a vu depuis la bataille ; les rares témoignages décrivent une silhouette sans ombre.",
"Secrets", "Son vrai nom est la clé du troisième sceau."))
.build()));
// --- Bestiaire ----------------------------------------------------------
when(enemyRepo.findByCampaignIdOrderByOrderAsc(1L)).thenReturn(List.of(
EnemyJpaEntity.builder().id(850L).campaignId(1L).order(0).name("Sbire des masques de suie")
.folder("Humanoïdes").level("1/2").portraitImageId("412")
.values(Map.of(
"Description", "Hommes de main recrutés dans les faubourgs, visage couvert de suie rituelle.",
"Tactique", "Attaquent en meute, fuient dès que le combat tourne mal."))
.keyValueValues(Map.of("Caractéristiques", Map.of(
"FOR", "12", "DEX", "14", "CON", "11", "INT", "9", "SAG", "10", "CHA", "8")))
.foundryStats(Map.of(
"attributes.hp.value", "11",
"attributes.hp.max", "11",
"attributes.ac.flat", "13",
"attributes.movement.walk", "30",
"details.creatureType", "humanoid",
"details.alignment", "neutral evil",
"attributes.spellcasting", "",
"details.rollMode", "public"))
.build(),
EnemyJpaEntity.builder().id(851L).campaignId(1L).order(1).name("Sentinelle de cendre")
.folder("Créatures de la Tour").level("4")
.values(Map.of(
"Description", "Armure vide animée par les cendres du champ de bataille ; s'effondre en tas de suie une fois vaincue.",
"Tactique", "Garde un point fixe ; ne poursuit jamais au-delà de 20 mètres de son sceau."))
.build()));
// --- Tables aleatoires ---------------------------------------------------
when(randomTableRepo.findByCampaignIdOrderByOrderAsc(1L)).thenReturn(List.of(
RandomTableJpaEntity.builder().id(950L).campaignId(1L).order(0)
.name("Rencontres dans la brume des Gués").diceFormula("1d6")
.description("À lancer par tranche de 4 heures passées dans le marais.")
.entries(List.of(
RandomTableEntryJpaEntity.builder().id(1L).position(0).minRoll(1).maxRoll(2)
.label("Nappe de brume dense").detail("Vision réduite à 3 mètres pendant 1d4 heures.").build(),
RandomTableEntryJpaEntity.builder().id(2L).position(1).minRoll(3).maxRoll(4)
.label("Échos de la bataille").detail("Des voix spectrales rejouent les Gués ; test de SAG DD 12 ou désorientation.").build(),
RandomTableEntryJpaEntity.builder().id(3L).position(2).minRoll(5).maxRoll(5)
.label("Patrouille des masques de suie").build(),
RandomTableEntryJpaEntity.builder().id(4L).position(3).minRoll(6).maxRoll(6)
.label("Une Sentinelle de cendre émerge de la vase").build()))
.build(),
RandomTableJpaEntity.builder().id(951L).campaignId(1L).order(1)
.name("Rumeurs au Chaudron Fêlé").diceFormula("1d4")
.entries(List.of(
RandomTableEntryJpaEntity.builder().id(5L).position(0).minRoll(1).maxRoll(1)
.label("« La Tour s'est rallumée, j'te dis. Trois nuits de suite. »").build(),
RandomTableEntryJpaEntity.builder().id(6L).position(1).minRoll(2).maxRoll(2)
.label("« Le colporteur ? Parti sans payer, comme un voleur. »").build(),
RandomTableEntryJpaEntity.builder().id(7L).position(2).minRoll(3).maxRoll(3)
.label("« Maëlis ? Elle n'est pas d'ici. Personne ne sait d'où elle vient. »").build(),
RandomTableEntryJpaEntity.builder().id(8L).position(3).minRoll(4).maxRoll(4)
.label("« Mon cousin du Guet dit qu'on a muré la crypte pour une bonne raison. »").build()))
.build()));
// --- Lore : dossiers + templates + pages --------------------------------
when(loreNodeRepo.findByLoreId(20L)).thenReturn(List.of(
LoreNodeJpaEntity.builder().id(600L).loreId(20L).name("Géographie").order(0).build(),
LoreNodeJpaEntity.builder().id(601L).loreId(20L).name("Factions").order(1).build(),
LoreNodeJpaEntity.builder().id(602L).loreId(20L).name("Le Nord").parentId(600L).order(0).build()));
when(templateRepo.findByLoreId(20L)).thenReturn(List.of(
TemplateJpaEntity.builder().id(700L).loreId(20L).name("Lieu")
.fields(List.of(
TemplateField.text("Résumé"),
TemplateField.text("Histoire"),
TemplateField.keyValueList("En bref", List.of("Population", "Dirigeant", "Ressources")),
TemplateField.table("Rumeurs", List.of("d6", "Rumeur", "Vraie ?"))))
.build()));
when(pageRepo.findByLoreId(20L)).thenReturn(List.of(
PageJpaEntity.builder().id(750L).loreId(20L).nodeId(602L).order(0).templateId(700L)
.title("Valfroide")
.values(Map.of(
"Résumé", "Bourg fortifié de deux mille âmes, dernier marché avant les terres hautes.",
"Histoire", "Fondée sur les ruines d'un poste de garde impérial, Valfroide a survécu à trois sièges et à un hiver de sept mois. On y respecte deux choses : la parole donnée et le feu bien entretenu."))
.keyValueValues(Map.of("En bref", Map.of(
"Population", "≈ 2 000", "Dirigeant", "Conseil des Cinq", "Ressources", "Laine, sel, minerai de fer")))
.tableValues(Map.of("Rumeurs", List.of(
Map.of("d6", "1-2", "Rumeur", "La Tour s'est rallumée ; on a vu de la lumière trois nuits de suite.", "Vraie ?", "Oui"),
Map.of("d6", "3-4", "Rumeur", "Le colporteur est parti sans payer sa taxe de foire.", "Vraie ?", "Non"),
Map.of("d6", "5-6", "Rumeur", "Le forgeron garde la clé d'une porte que personne n'a jamais vue.", "Vraie ?", "Oui"))))
.build(),
PageJpaEntity.builder().id(751L).loreId(20L).nodeId(601L).order(0)
.title("Les masques de suie")
.values(Map.of("Description", "Réseau de mercenaires et d'informateurs au service d'un commanditaire inconnu. Signe de reconnaissance : une trace de suie sous l'œil gauche."))
.build()));
// --- Images placeholder (portraits, illustration, battlemaps) ----------
when(imageRepo.findById(anyLong())).thenAnswer(inv -> {
Long id = inv.getArgument(0);
return Optional.of(ImageJpaEntity.builder().id(id).storageKey("img-" + id).contentType("image/png").build());
});
when(storedFileRepo.findById(anyLong())).thenAnswer(inv -> {
Long id = inv.getArgument(0);
return Optional.of(StoredFileJpaEntity.builder().id(id).storageKey("map-" + id).contentType("image/png").build());
});
when(imageStorage.download(anyString())).thenAnswer(inv -> placeholder(inv.getArgument(0), false));
when(fileStorage.download(anyString())).thenAnswer(inv -> placeholder(inv.getArgument(0), true));
PdfExportService service = new PdfExportService(campaignRepo, arcRepo, chapterRepo, sceneRepo,
questRepo, npcRepo, enemyRepo, randomTableRepo, gameSystemRepo, imageRepo, storedFileRepo,
loreNodeRepo, pageRepo, templateRepo, imageStorage, fileStorage);
byte[] pdf = service.export("1");
assertEquals("%PDF-", new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII));
assertTrue(pdf.length > 10_000, "le livret de démo doit être substantiel");
Path out = Path.of("target", "campaign-preview.pdf");
Files.createDirectories(out.getParent());
Files.write(out, pdf);
System.out.println("[preview] PDF écrit : " + out.toAbsolutePath());
}
/** Image placeholder deterministe : portrait carre ou battlemap avec grille. */
private static InputStream placeholder(String key, boolean map) throws Exception {
int w = map ? 1200 : 600, h = map ? 800 : 700;
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
int hue = Math.abs(key.hashCode()) % 360;
g.setPaint(new GradientPaint(0, 0, Color.getHSBColor(hue / 360f, 0.35f, 0.75f),
w, h, Color.getHSBColor(hue / 360f, 0.55f, 0.35f)));
g.fillRect(0, 0, w, h);
if (map) {
g.setColor(new Color(255, 255, 255, 70));
for (int x = 0; x < w; x += 60) g.drawLine(x, 0, x, h);
for (int y = 0; y < h; y += 60) g.drawLine(0, y, w, y);
}
g.setColor(Color.WHITE);
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 40));
g.drawString(key, 30, h / 2);
g.dispose();
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(img, "png", out);
return new ByteArrayInputStream(out.toByteArray());
}
}

View File

@@ -82,13 +82,6 @@ class NpcControllerTest {
.andExpect(jsonPath("$").isArray()); .andExpect(jsonPath("$").isArray());
} }
@Test
void getByLore_returnsArray() throws Exception {
mockMvc.perform(get("/api/npcs/lore/{loreId}", "lore-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test @Test
void update_returns200() throws Exception { void update_returns200() throws Exception {
Npc saved = npcRepository.save(Npc.builder().campaignId(campaignId).name("old").order(0).build()); Npc saved = npcRepository.save(Npc.builder().campaignId(campaignId).name("old").order(0).build());

View File

@@ -44,7 +44,7 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "10kb", "maximumWarning": "12kb",
"maximumError": "16kb" "maximumError": "16kb"
} }
] ]

4
web/package-lock.json generated
View File

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

View File

@@ -1,11 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "1.0.0-beta", "version": "1.0.0",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "npm run i18n:check && ng build",
"i18n:check": "node scripts/check-i18n.mjs",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"test:unit": "vitest run", "test:unit": "vitest run",

127
web/scripts/check-i18n.mjs Normal file
View File

@@ -0,0 +1,127 @@
// Vérificateur de cohérence i18n.
//
// Les traductions vivent en DOUBLE : les fichiers consolidés (src/assets/i18n/fr.json,
// en.json — ceux que charge ngx-translate) et les fragments de maintenance
// (src/assets/i18n/fragments/*.fr.json / *.en.json). Une section peut être alimentée
// par PLUSIEURS fragments (ex. playthroughDetail) : un fragment est donc un
// SOUS-ENSEMBLE du consolidé, pas une copie exacte.
//
// ERREURS (code retour 1) :
// 1. clé de fragment absente du consolidé, ou valeur différente (le consolidé est
// ce que voit l'utilisateur : un fragment en avance = trad perdue en prod) ;
// 2. deux fragments qui définissent la même clé avec des valeurs contradictoires ;
// 3. parité structurelle fr/en rompue (fr.json vs en.json, et chaque paire de
// fragments x.fr.json / x.en.json) ;
// 4. JSON invalide ou paire de fragments incomplète.
//
// AVERTISSEMENTS (non bloquants) : clés du consolidé couvertes par aucun fragment
// (dette de maintenance, pas un bug utilisateur).
//
// Usage : node scripts/check-i18n.mjs
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const webDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const i18nDir = path.join(webDir, 'src', 'assets', 'i18n');
const fragmentsDir = path.join(i18nDir, 'fragments');
const errors = [];
const warnings = [];
function loadJson(file) {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch (e) {
errors.push(`${path.relative(webDir, file)} : JSON invalide (${e.message})`);
return null;
}
}
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
/** Feuilles d'un objet : Map "a.b.c" -> valeur (les tableaux sont des feuilles). */
function leaves(obj, prefix = '', out = new Map()) {
for (const [key, value] of Object.entries(obj)) {
const p = prefix ? `${prefix}.${key}` : key;
if (isObject(value)) leaves(value, p, out);
else out.set(p, value);
}
return out;
}
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** Parité structurelle : mêmes chemins de feuilles des deux côtés. */
function structuralDiff(labelA, a, labelB, b) {
const pathsA = leaves(a);
const pathsB = leaves(b);
for (const p of pathsA.keys()) if (!pathsB.has(p)) errors.push(`${labelB} : clé manquante « ${p} » (présente dans ${labelA})`);
for (const p of pathsB.keys()) if (!pathsA.has(p)) errors.push(`${labelA} : clé manquante « ${p} » (présente dans ${labelB})`);
}
// --- Chargement -------------------------------------------------------------
const consolidated = {
fr: loadJson(path.join(i18nDir, 'fr.json')),
en: loadJson(path.join(i18nDir, 'en.json'))
};
const consolidatedLeaves = {
fr: consolidated.fr ? leaves(consolidated.fr) : new Map(),
en: consolidated.en ? leaves(consolidated.en) : new Map()
};
const fragmentFiles = readdirSync(fragmentsDir).filter(f => f.endsWith('.json')).sort();
// --- 1 + 2 : chaque clé de fragment doit exister À L'IDENTIQUE au consolidé ---
const fragmentLeafOwner = { fr: new Map(), en: new Map() };
for (const file of fragmentFiles) {
const match = file.match(/^(.+)\.(fr|en)\.json$/);
if (!match) { errors.push(`fragments/${file} : nom inattendu (attendu <nom>.fr.json / <nom>.en.json)`); continue; }
const lang = match[2];
const fragment = loadJson(path.join(fragmentsDir, file));
if (!fragment || !consolidated[lang]) continue;
for (const [leafPath, value] of leaves(fragment)) {
const owner = fragmentLeafOwner[lang].get(leafPath);
if (owner && !sameValue(owner.value, value)) {
errors.push(`Clé « ${leafPath} » (${lang}) contradictoire entre fragments/${owner.file} et fragments/${file}`);
}
fragmentLeafOwner[lang].set(leafPath, { file, value });
if (!consolidatedLeaves[lang].has(leafPath)) {
errors.push(`${lang}.json : clé « ${leafPath} » manquante (définie par fragments/${file})`);
} else if (!sameValue(consolidatedLeaves[lang].get(leafPath), value)) {
errors.push(`${lang}.json ≠ fragments/${file} : valeur différente pour « ${leafPath} »`);
}
}
}
// --- 3 : parité structurelle fr/en -------------------------------------------
if (consolidated.fr && consolidated.en) structuralDiff('fr.json', consolidated.fr, 'en.json', consolidated.en);
const fragmentNames = new Set(fragmentFiles.map(f => f.replace(/\.(fr|en)\.json$/, '')));
for (const name of fragmentNames) {
const frFile = `${name}.fr.json`;
const enFile = `${name}.en.json`;
if (!fragmentFiles.includes(frFile) || !fragmentFiles.includes(enFile)) {
errors.push(`fragments/${name} : paire fr/en incomplète`);
continue;
}
const fr = loadJson(path.join(fragmentsDir, frFile));
const en = loadJson(path.join(fragmentsDir, enFile));
if (fr && en) structuralDiff(`fragments/${frFile}`, fr, `fragments/${enFile}`, en);
}
// --- Avertissements : clés consolidées sans fragment (dette, non bloquant) ----
for (const lang of ['fr']) { // fr suffit : la parité fr/en est déjà vérifiée
const uncovered = [...consolidatedLeaves[lang].keys()].filter(p => !fragmentLeafOwner[lang].has(p));
if (uncovered.length > 0) {
warnings.push(`${uncovered.length} clé(s) de ${lang}.json sans fragment (dette de maintenance), ex. : ${uncovered.slice(0, 5).join(', ')}`);
}
}
// --- Rapport ------------------------------------------------------------------
for (const w of warnings) console.warn(`i18n (avertissement) : ${w}`);
if (errors.length > 0) {
console.error(`\ni18n : ${errors.length} incohérence(s) bloquante(s) :\n`);
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log(`i18n OK — ${Object.keys(consolidated.fr ?? {}).length} sections, ${consolidatedLeaves.fr.size} clés, ${fragmentFiles.length} fragments.`);

View File

@@ -4,7 +4,6 @@ import { hiddenInDemoGuard } from './guards/demo-mode.guard';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) }, { path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) }, { path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
{ path: 'lore/:loreId/graph', loadComponent: () => import('./lore/lore-graph/lore-graph.component').then(m => m.LoreGraphComponent) },
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) }, { path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
@@ -17,6 +16,7 @@ export const routes: Routes = [
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) }, { 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', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/graph', loadComponent: () => import('./campaigns/campaign-graph/campaign-graph.component').then(m => m.CampaignGraphComponent) },
{ path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) }, { path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) },

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -109,7 +110,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
name: ['', Validators.required], name: ['', Validators.required],
@@ -127,7 +129,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular // On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
// réutilise le composant quand on navigue entre arcs frères via l'arbre // réutilise le composant quand on navigue entre arcs frères via l'arbre
// (même route pattern), et ngOnInit ne se relance pas. // (même route pattern), et ngOnInit ne se relance pas.
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!; const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!; const newArcId = pm.get('arcId')!;
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) { if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {

View File

@@ -0,0 +1,101 @@
@if (campaign) {
<div class="graph-page">
<header class="graph-header">
<button type="button" class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
{{ 'campaignGraph.back' | translate }}
</button>
<div class="graph-title">
<h1>
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
{{ 'campaignGraph.title' | translate:{ name: campaign.name } }}
</h1>
<p class="graph-subtitle">
{{ 'campaignGraph.subtitle' | translate:{ pages: pageCount, npcs: npcCount, scenes: sceneCount, quests: questCount, edges: edgeCount } }}
</p>
</div>
<!-- Légende cliquable : masquer/afficher un type d'entité (préférence mémorisée). -->
<div class="graph-legend">
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> {{ 'campaignGraph.legendPage' | translate }}</span>
<button type="button" class="legend-item legend-toggle" [class.legend-toggle--off]="isHidden('npc')"
(click)="toggleKind('npc')" [title]="'campaignGraph.toggleHint' | translate">
<span class="legend-dot legend-dot--npc"></span> {{ 'campaignGraph.legendNpc' | translate }}
</button>
<button type="button" class="legend-item legend-toggle" [class.legend-toggle--off]="isHidden('scene')"
(click)="toggleKind('scene')" [title]="'campaignGraph.toggleHint' | translate">
<span class="legend-dot legend-dot--scene"></span> {{ 'campaignGraph.legendScene' | translate }}
</button>
<button type="button" class="legend-item legend-toggle" [class.legend-toggle--off]="isHidden('quest')"
(click)="toggleKind('quest')" [title]="'campaignGraph.toggleHint' | translate">
<span class="legend-dot legend-dot--quest"></span> {{ 'campaignGraph.legendQuest' | translate }}
</button>
</div>
<button type="button" class="btn-back" (click)="resetLayout()"
[title]="'campaignGraph.resetTitle' | translate">
<lucide-icon [img]="RotateCcw" [size]="14"></lucide-icon>
{{ 'campaignGraph.reset' | translate }}
</button>
</header>
@if (!hasLore) {
<div class="graph-empty">
{{ 'campaignGraph.noLore' | translate }}
</div>
} @else if (nodes.length === 0) {
<div class="graph-empty">
{{ 'campaignGraph.empty' | translate }}
</div>
} @else {
<div class="graph-scroll">
<svg #svgEl
[attr.width]="svgWidth"
[attr.height]="svgHeight"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointerleave)="onPointerUp($event)">
<!-- Arêtes (sous les nœuds) — estompées quand un autre nœud a le focus -->
@for (e of edges; track e.key) {
<line
[attr.x1]="e.x1" [attr.y1]="e.y1"
[attr.x2]="e.x2" [attr.y2]="e.y2"
[class.edge-page]="e.kind === 'page'"
[class.edge-npc]="e.kind === 'npc'"
[class.edge-scene]="e.kind === 'scene'"
[class.edge-quest]="e.kind === 'quest'"
[class.dimmed]="edgeDimmed(e)" />
}
<!-- Nœuds : survol = focus sur le nœud et ses voisins -->
@for (n of nodes; track n.id) {
<g class="node"
[class.node--npc]="n.kind === 'npc'"
[class.node--scene]="n.kind === 'scene'"
[class.node--quest]="n.kind === 'quest'"
[class.dragging]="draggingId === n.id"
[class.dimmed]="nodeDimmed(n)"
(pointerenter)="onNodeEnter(n)"
(pointerleave)="onNodeLeave()"
(pointerdown)="onPointerDown($event, n)">
<circle [attr.cx]="n.x" [attr.cy]="n.y" [attr.r]="radiusOf(n)" />
<text class="node-label"
[attr.x]="n.x"
[attr.y]="n.y + radiusOf(n) + 14"
text-anchor="middle">
@for (line of n.lines; track $index) {
<tspan [attr.x]="n.x" [attr.dy]="$index === 0 ? 0 : 12">{{ line }}</tspan>
}
</text>
<title>{{ n.label }}</title>
</g>
}
</svg>
</div>
@if (edgeCount === 0) {
<p class="graph-hint">
{{ 'campaignGraph.hint' | translate }}
</p>
}
}
</div>
}

View File

@@ -57,6 +57,24 @@
gap: 0.45rem; gap: 0.45rem;
} }
// Entrées cliquables : masquer/afficher un type d'entité.
.legend-toggle {
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
&:hover { color: #e5e7eb; }
&.legend-toggle--off {
opacity: 0.4;
text-decoration: line-through;
}
}
.legend-dot { .legend-dot {
width: 12px; width: 12px;
height: 12px; height: 12px;
@@ -65,6 +83,8 @@
&.legend-dot--page { background: #4338ca; border: 1px solid #818cf8; } &.legend-dot--page { background: #4338ca; border: 1px solid #818cf8; }
&.legend-dot--npc { background: #92400e; border: 1px solid #fbbf24; } &.legend-dot--npc { background: #92400e; border: 1px solid #fbbf24; }
&.legend-dot--scene { background: #365314; border: 1px solid #a3e635; }
&.legend-dot--quest { background: #581c87; border: 1px solid #d8b4fe; }
} }
.legend-line { .legend-line {
@@ -91,6 +111,13 @@ svg {
display: block; display: block;
touch-action: none; // requis pour le drag au pointeur sur tactile touch-action: none; // requis pour le drag au pointeur sur tactile
// Focus au survol : tout ce qui n'est pas le nœud survolé ou un voisin s'estompe.
line, .node {
transition: opacity 0.12s;
&.dimmed { opacity: 0.12; }
}
line { line {
&.edge-page { &.edge-page {
stroke: #4f4f7a; stroke: #4f4f7a;
@@ -103,6 +130,20 @@ svg {
stroke-dasharray: 5 4; stroke-dasharray: 5 4;
opacity: 0.8; opacity: 0.8;
} }
&.edge-scene {
stroke: #65a30d;
stroke-width: 1.4;
stroke-dasharray: 5 4;
opacity: 0.8;
}
&.edge-quest {
stroke: #a855f7;
stroke-width: 1.4;
stroke-dasharray: 5 4;
opacity: 0.8;
}
} }
.node { .node {
@@ -143,6 +184,36 @@ svg {
stroke: #fbbf24; stroke: #fbbf24;
} }
} }
// Scènes : palette verte (cohérente avec les cartes de scène de l'app).
&.node--scene {
circle {
fill: #1a2e05;
stroke: #65a30d;
}
.node-label { fill: #d9f99d; }
&:hover circle {
fill: #365314;
stroke: #a3e635;
}
}
// Quêtes : palette pourpre claire, distincte de l'indigo des pages.
&.node--quest {
circle {
fill: #3b0764;
stroke: #a855f7;
}
.node-label { fill: #e9d5ff; }
&:hover circle {
fill: #581c87;
stroke: #c084fc;
}
}
} }
} }

View File

@@ -0,0 +1,688 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { LucideAngularModule, ArrowLeft, Network, RotateCcw } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService } 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, buildCampaignSidebarConfig, CampaignTreeData } from '../campaign-tree.helper';
/** Type d'un nœud du graphe (préfixe des ids, style et légende). */
type NodeKind = 'page' | 'npc' | 'scene' | 'quest';
/** Nœud du graphe : une page de Lore, ou une entité de campagne qui référence des pages. */
interface GraphNode {
id: string; // '<kind>:<id>' (évite les collisions d'IDs entre tables)
kind: NodeKind;
label: string;
/** Libellé affiché sur 1 à 2 lignes (moins de troncature qu'une ligne unique). */
lines: string[];
route: string[]; // navigation au clic
x: number; // centre du nœud (coords SVG)
y: number;
degree: number; // nombre de liens (taille du nœud)
}
interface GraphEdge {
key: string;
kind: NodeKind; // page↔page ou entité→page (style distinct par type)
a: string; // ids des nœuds reliés — pour le focus au survol
b: string;
x1: number; y1: number; x2: number; y2: number;
}
/**
* Graphe des liens d'une CAMPAGNE : ses PNJ, scènes et quêtes reliés aux pages
* de son univers (Lore).
*
* Nœuds = toutes les pages du Lore associé + les entités de la campagne (PNJ,
* scènes, quêtes) qui référencent au moins une page. Arêtes = `relatedPageIds`
* des pages (liens page↔page) et des entités (liens entité→page).
*
* Vivait historiquement côté Lore ; déplacé côté campagne car il mélange des
* entités de campagne (PNJ) avec les pages — c'est une vue de PRÉPARATION de
* campagne, pas une vue d'univers.
*
* Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au
* chargement, puis nœuds déplaçables à la souris — même approche SVG custom
* que chapter-graph, sans dépendance externe.
*/
@Component({
selector: 'app-campaign-graph',
imports: [RouterModule, LucideAngularModule, TranslatePipe],
templateUrl: './campaign-graph.component.html',
styleUrls: ['./campaign-graph.component.scss']
})
export class CampaignGraphComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Network = Network;
readonly RotateCcw = RotateCcw;
campaignId = '';
campaign: Campaign | null = null;
/** Faux tant que la campagne n'a pas d'univers (Lore) associé → état vide dédié. */
hasLore = false;
nodes: GraphNode[] = [];
edges: GraphEdge[] = [];
npcCount = 0;
sceneCount = 0;
questCount = 0;
pageCount = 0;
edgeCount = 0;
readonly MAX_LABEL_CHARS = 22;
private readonly MARGIN = 70;
svgWidth = 800;
svgHeight = 600;
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
draggingId: string | null = null;
private dragOffsetX = 0;
private dragOffsetY = 0;
private dragMoved = false;
private readonly DRAG_THRESHOLD = 4;
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
private adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
// Disposition personnalisée : positions sauvegardées sur la campagne, sauvegarde
// différée après un drag (évite un PUT par pixel déplacé).
private savedPositions: Record<string, { x: number; y: number }> | null = null;
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private positionsDirty = false;
// Filtres de la légende : types d'entités masqués (persistés en localStorage).
private static readonly HIDDEN_KINDS_KEY = 'loremind.campaignGraph.hiddenKinds';
hiddenKinds = new Set<NodeKind>();
// Données brutes gardées pour reconstruire le graphe quand on (dé)masque un type.
private cachedPages: Page[] = [];
private cachedTree: CampaignTreeData | null = null;
// Focus au survol : le nœud survolé + ses voisins restent nets, le reste s'estompe.
hoveredId: string | null = null;
private hoverNeighbors = new Set<string>();
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private translate: TranslateService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
try {
const raw = localStorage.getItem(CampaignGraphComponent.HIDDEN_KINDS_KEY);
if (raw) this.hiddenKinds = new Set(JSON.parse(raw) as NodeKind[]);
} catch { /* préférence corrompue → tout visible */ }
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).pipe(
switchMap(({ campaign, allCampaigns, treeData }) => {
this.campaign = campaign;
this.hasLore = !!campaign.loreId;
this.savedPositions = this.parsePositions(campaign.graphPositions);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate));
this.pageTitleService.set(this.translate.instant('campaignGraph.title', { name: campaign.name }));
// Les PNJ arrivent déjà avec l'arbre (une seule requête /tree) ; seules
// les pages du Lore associé restent à charger.
const pages$ = campaign.loreId ? this.pageService.getByLoreId(campaign.loreId) : of([] as Page[]);
return pages$.pipe(map(pages => ({ pages, treeData })));
})
).subscribe(({ pages, treeData }) => {
this.cachedPages = pages;
this.cachedTree = treeData;
this.buildGraph(pages, treeData);
});
}
// --- Filtres de la légende --------------------------------------------------
isHidden(kind: NodeKind): boolean {
return this.hiddenKinds.has(kind);
}
/** (Dé)masque un type d'entité et reconstruit le graphe (positions sauvées conservées). */
toggleKind(kind: NodeKind): void {
if (this.hiddenKinds.has(kind)) this.hiddenKinds.delete(kind);
else this.hiddenKinds.add(kind);
try {
localStorage.setItem(CampaignGraphComponent.HIDDEN_KINDS_KEY, JSON.stringify([...this.hiddenKinds]));
} catch { /* stockage indisponible : préférence non persistée */ }
this.hoveredId = null;
this.hoverNeighbors.clear();
if (this.cachedTree) this.buildGraph(this.cachedPages, this.cachedTree);
}
// --- Focus au survol ----------------------------------------------------------
onNodeEnter(node: GraphNode): void {
if (this.draggingId) return; // pas de changement de focus en plein drag
this.hoveredId = node.id;
this.hoverNeighbors = new Set(
this.adjacency.flatMap(e => e.a === node.id ? [e.b] : e.b === node.id ? [e.a] : []));
}
onNodeLeave(): void {
this.hoveredId = null;
this.hoverNeighbors.clear();
}
nodeDimmed(node: GraphNode): boolean {
return this.hoveredId !== null && node.id !== this.hoveredId && !this.hoverNeighbors.has(node.id);
}
edgeDimmed(edge: GraphEdge): boolean {
return this.hoveredId !== null && edge.a !== this.hoveredId && edge.b !== this.hoveredId;
}
// --- Construction du graphe ----------------------------------------------
private buildGraph(pages: Page[], treeData: CampaignTreeData): void {
const loreId = this.campaign?.loreId ?? '';
const pageIds = new Set(pages.map(p => p.id!));
this.pageCount = pages.length;
// Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines).
const nodes: GraphNode[] = pages.map(p => ({
id: `page:${p.id}`,
kind: 'page' as const,
label: p.title,
lines: this.splitLabel(p.title),
route: ['/lore', loreId, 'pages', p.id!],
x: 0, y: 0, degree: 0
}));
const linksTo = (ids?: string[]) => (ids ?? []).some(pid => pageIds.has(pid));
// Entités de campagne : seulement celles qui référencent au moins une page du
// Lore (une entité sans lien n'apporte rien à la carte), et dont le TYPE n'est
// pas masqué par la légende.
const linkedNpcs = this.hiddenKinds.has('npc') ? [] : treeData.npcs.filter(n => linksTo(n.relatedPageIds));
for (const n of linkedNpcs) {
nodes.push({
id: `npc:${n.id}`,
kind: 'npc',
label: n.name,
lines: this.splitLabel(n.name),
route: ['/campaigns', this.campaignId, 'npcs', n.id!],
x: 0, y: 0, degree: 0
});
}
this.npcCount = linkedNpcs.length;
// Scènes liées : la route de détail exige arc + chapitre → index inverse depuis l'arbre.
const arcOfChapter = new Map<string, string>();
for (const [arcId, chapters] of Object.entries(treeData.chaptersByArc)) {
for (const ch of chapters) arcOfChapter.set(ch.id!, arcId);
}
const linkedScenes: Array<{ scene: Scene; chapterId: string }> = [];
if (!this.hiddenKinds.has('scene')) {
for (const [chapterId, scenes] of Object.entries(treeData.scenesByChapter)) {
if (!arcOfChapter.has(chapterId)) continue;
for (const sc of scenes) {
if (linksTo(sc.relatedPageIds)) linkedScenes.push({ scene: sc, chapterId });
}
}
}
for (const { scene, chapterId } of linkedScenes) {
nodes.push({
id: `scene:${scene.id}`,
kind: 'scene',
label: scene.name,
lines: this.splitLabel(scene.name),
route: ['/campaigns', this.campaignId, 'arcs', arcOfChapter.get(chapterId)!,
'chapters', chapterId, 'scenes', scene.id!],
x: 0, y: 0, degree: 0
});
}
this.sceneCount = linkedScenes.length;
const linkedQuests = this.hiddenKinds.has('quest')
? [] : (treeData.quests ?? []).filter(q => linksTo(q.relatedPageIds));
for (const q of linkedQuests) {
nodes.push({
id: `quest:${q.id}`,
kind: 'quest',
label: q.name,
lines: this.splitLabel(q.name),
route: ['/campaigns', this.campaignId, 'quests', q.id!],
x: 0, y: 0, degree: 0
});
}
this.questCount = linkedQuests.length;
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
// (A→B et B→A = un seul trait) ; les liens entité→page portent le type de l'entité.
const adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
const seenPairs = new Set<string>();
for (const p of pages) {
for (const targetId of p.relatedPageIds ?? []) {
if (!pageIds.has(targetId) || targetId === p.id) continue;
const pair = [p.id!, targetId].sort().join('|');
if (seenPairs.has(pair)) continue;
seenPairs.add(pair);
adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` });
}
}
const entityEdges = (kind: NodeKind, id: string, relatedPageIds?: string[]) => {
for (const targetId of new Set(relatedPageIds ?? [])) {
if (!pageIds.has(targetId)) continue;
adjacency.push({ key: `${kind}:${id}|${targetId}`, kind, a: `${kind}:${id}`, b: `page:${targetId}` });
}
};
for (const n of linkedNpcs) entityEdges('npc', n.id!, n.relatedPageIds);
for (const { scene } of linkedScenes) entityEdges('scene', scene.id!, scene.relatedPageIds);
for (const q of linkedQuests) entityEdges('quest', q.id!, q.relatedPageIds);
this.adjacency = adjacency;
this.edgeCount = adjacency.length;
// Degré (pondère la taille des nœuds : une capitale très liée ressort).
const degree = new Map<string, number>();
for (const e of adjacency) {
degree.set(e.a, (degree.get(e.a) ?? 0) + 1);
degree.set(e.b, (degree.get(e.b) ?? 0) + 1);
}
for (const node of nodes) {
node.degree = degree.get(node.id) ?? 0;
}
this.nodes = nodes;
this.runForceLayout();
this.applySavedPositions();
this.recomputeEdges();
}
// --- Disposition personnalisée (persistée sur la campagne) -----------------
private parsePositions(json: string | null | undefined): Record<string, { x: number; y: number }> | null {
if (!json) return null;
try {
const parsed = JSON.parse(json);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null; // JSON corrompu → disposition auto, sans casser l'écran
}
}
/** Réapplique les positions sauvegardées ; les nœuds inconnus gardent le layout auto. */
private applySavedPositions(): void {
if (!this.savedPositions) return;
let applied = false;
for (const node of this.nodes) {
const p = this.savedPositions[node.id];
if (!p || typeof p.x !== 'number' || typeof p.y !== 'number') continue;
node.x = p.x;
node.y = p.y;
applied = true;
}
if (applied) this.fitSvgToNodes();
}
/** Sauvegarde différée (800 ms après le dernier drag) de la disposition courante. */
private scheduleSave(): void {
this.positionsDirty = true;
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.savePositions(), 800);
}
private savePositions(): void {
if (!this.positionsDirty || !this.campaign?.id) return;
this.positionsDirty = false;
const positions: Record<string, { x: number; y: number }> = {};
for (const n of this.nodes) positions[n.id] = { x: Math.round(n.x), y: Math.round(n.y) };
this.savedPositions = positions;
this.campaignService.updateGraphPositions(this.campaign.id, JSON.stringify(positions)).subscribe();
}
/** Recalcule la disposition automatique et oublie les déplacements manuels. */
resetLayout(): void {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.positionsDirty = false;
this.savedPositions = null;
this.runForceLayout();
this.recomputeEdges();
if (this.campaign?.id) {
this.campaignService.updateGraphPositions(this.campaign.id, '').subscribe();
}
}
/**
* Layout force-directed (Fruchterman-Reingold simplifié) :
* répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le
* centre (regroupe les composantes déconnectées), refroidissement progressif.
* Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf.
* convention projet d'éviter Math.random pour des rendus reproductibles).
*/
private runForceLayout(): void {
const n = this.nodes.length;
if (n === 0) {
this.svgWidth = 800; this.svgHeight = 400;
return;
}
const side = Math.max(600, Math.ceil(170 * Math.sqrt(n)));
const w = side, h = side;
const cx = w / 2, cy = h / 2;
// Init en spirale : angle d'or → répartition uniforme et déterministe.
const golden = Math.PI * (3 - Math.sqrt(5));
this.nodes.forEach((node, i) => {
const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN);
const a = i * golden;
node.x = cx + r * Math.cos(a);
node.y = cy + r * Math.sin(a);
});
const index = new Map(this.nodes.map(node => [node.id, node]));
const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds
let temperature = side / 8;
for (let iter = 0; iter < 300; iter++) {
const dx = new Map<string, number>();
const dy = new Map<string, number>();
for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); }
// Répulsion entre toutes les paires.
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = this.nodes[i], b = this.nodes[j];
let vx = a.x - b.x, vy = a.y - b.y;
let d = Math.hypot(vx, vy);
if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); }
const force = (k * k) / d;
dx.set(a.id, dx.get(a.id)! + (vx / d) * force);
dy.set(a.id, dy.get(a.id)! + (vy / d) * force);
dx.set(b.id, dx.get(b.id)! - (vx / d) * force);
dy.set(b.id, dy.get(b.id)! - (vy / d) * force);
}
}
// Attraction le long des arêtes.
for (const e of this.adjacency) {
const a = index.get(e.a)!, b = index.get(e.b)!;
const vx = a.x - b.x, vy = a.y - b.y;
const d = Math.max(0.01, Math.hypot(vx, vy));
const force = (d * d) / k;
dx.set(a.id, dx.get(a.id)! - (vx / d) * force);
dy.set(a.id, dy.get(a.id)! - (vy / d) * force);
dx.set(b.id, dx.get(b.id)! + (vx / d) * force);
dy.set(b.id, dy.get(b.id)! + (vy / d) * force);
}
// Gravité douce vers le centre (sinon les composantes isolées fuient).
for (const node of this.nodes) {
dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06);
dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06);
}
// Application bornée par la température, SANS clamp au cadre : le clamp à
// chaque itération écrasait des rangées de nœuds contre les bords (libellés
// illisibles). Le cadrage se fait après coup dans normalizeToFrame().
for (const node of this.nodes) {
const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!;
const d = Math.max(0.01, Math.hypot(ddx, ddy));
const step = Math.min(d, temperature);
node.x += (ddx / d) * step;
node.y += (ddy / d) * step;
}
temperature *= 0.96;
}
// Sans clamp, le nuage libre est très étendu (la gravité n'équilibre la
// répulsion qu'à grande distance) : on le remet d'abord à l'échelle du cadre
// cible, puis l'anti-chevauchement ré-étend LOCALEMENT là où c'est nécessaire.
this.rescaleTo(side);
this.fanOutLeaves();
this.resolveLabelOverlaps();
this.normalizeToFrame();
}
/**
* Dispose en ÉVENTAIL les feuilles (degré 1) autour de leur hub (degré ≥ 3) :
* huit PNJ pointant le même lieu forment une couronne régulière au lieu d'un
* amas. Angles déterministes (départ = direction hub→extérieur du nuage).
*/
private fanOutLeaves(): void {
const neighbors = new Map<string, string[]>();
for (const e of this.adjacency) {
(neighbors.get(e.a) ?? neighbors.set(e.a, []).get(e.a)!).push(e.b);
(neighbors.get(e.b) ?? neighbors.set(e.b, []).get(e.b)!).push(e.a);
}
const leavesByHub = new Map<string, GraphNode[]>();
for (const node of this.nodes) {
const nbs = neighbors.get(node.id) ?? [];
if (nbs.length !== 1) continue;
const hubId = nbs[0];
if ((neighbors.get(hubId)?.length ?? 0) < 3) continue;
(leavesByHub.get(hubId) ?? leavesByHub.set(hubId, []).get(hubId)!).push(node);
}
if (leavesByHub.size === 0) return;
const index = new Map(this.nodes.map(n => [n.id, n]));
const cx = this.nodes.reduce((s, n) => s + n.x, 0) / this.nodes.length;
const cy = this.nodes.reduce((s, n) => s + n.y, 0) / this.nodes.length;
for (const [hubId, leaves] of leavesByHub) {
const hub = index.get(hubId);
if (!hub) continue;
leaves.sort((a, b) => a.label.localeCompare(b.label)); // ordre stable
const radius = 85 + leaves.length * 6;
// Première feuille vers l'extérieur du nuage : la couronne empiète moins
// sur le cœur du graphe.
const start = Math.atan2(hub.y - cy, hub.x - cx);
const step = (2 * Math.PI) / leaves.length;
leaves.forEach((leaf, i) => {
leaf.x = hub.x + radius * Math.cos(start + i * step);
leaf.y = hub.y + radius * Math.sin(start + i * step);
});
}
}
/** Réduit homothétiquement le nuage pour tenir dans un carré `side` (jamais d'agrandissement). */
private rescaleTo(side: number): void {
const xs = this.nodes.map(n => n.x), ys = this.nodes.map(n => n.y);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const span = Math.max(1, maxX - minX, maxY - minY);
const scale = Math.min(1, (side - 2 * this.MARGIN) / span);
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
for (const node of this.nodes) {
node.x = cx + (node.x - cx) * scale;
node.y = cy + (node.y - cy) * scale;
}
}
/** Largeur approximative du libellé affiché (police 11px ≈ 6.4px/caractère, ligne la plus longue). */
private labelWidth(node: GraphNode): number {
const longest = node.lines.reduce((max, l) => Math.max(max, l.length), 0);
return longest * 6.4 + 14;
}
/**
* Anti-chevauchement TENANT COMPTE DES LIBELLÉS : le layout de force espace les
* centres des cercles, mais un libellé fait ~120px de large — deux nœuds voisins
* se lisaient l'un sur l'autre. Écarte itérativement chaque paire trop proche
* selon l'axe le moins coûteux. Déterministe (pas d'aléatoire).
*/
private resolveLabelOverlaps(): void {
const nodes = this.nodes;
for (let iter = 0; iter < 80; iter++) {
let moved = false;
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
const needX = (this.labelWidth(a) + this.labelWidth(b)) / 2 + 6;
const needY = this.radiusOf(a) + this.radiusOf(b) + 42; // cercles + jusqu'à 2 lignes de texte
const dx = b.x - a.x, dy = b.y - a.y;
if (Math.abs(dx) >= needX || Math.abs(dy) >= needY) continue;
const overlapX = needX - Math.abs(dx);
const overlapY = needY - Math.abs(dy);
if (overlapX / needX < overlapY / needY) {
const shift = overlapX / 2 + 0.5;
const sign = dx !== 0 ? Math.sign(dx) : (i % 2 === 0 ? 1 : -1);
a.x -= sign * shift;
b.x += sign * shift;
} else {
const shift = overlapY / 2 + 0.5;
const sign = dy !== 0 ? Math.sign(dy) : (i % 2 === 0 ? 1 : -1);
a.y -= sign * shift;
b.y += sign * shift;
}
moved = true;
}
}
if (!moved) break;
}
}
/**
* Recadre le nuage dans le SVG : translation pour que tout (cercles ET libellés)
* soit visible avec une marge, taille du SVG = étendue réelle du graphe.
*/
private normalizeToFrame(): void {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of this.nodes) {
const half = Math.max(this.labelWidth(node) / 2, this.radiusOf(node));
minX = Math.min(minX, node.x - half);
maxX = Math.max(maxX, node.x + half);
minY = Math.min(minY, node.y - this.radiusOf(node));
maxY = Math.max(maxY, node.y + this.radiusOf(node) + 38); // libellé (2 lignes max) sous le cercle
}
const pad = 24;
for (const node of this.nodes) {
node.x += pad - minX;
node.y += pad - minY;
}
this.svgWidth = Math.max(600, Math.ceil(maxX - minX + pad * 2));
this.svgHeight = Math.max(400, Math.ceil(maxY - minY + pad * 2));
}
/** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */
private recomputeEdges(): void {
const index = new Map(this.nodes.map(n => [n.id, n]));
this.edges = this.adjacency
.filter(e => index.has(e.a) && index.has(e.b))
.map(e => {
const a = index.get(e.a)!, b = index.get(e.b)!;
return { key: e.key, kind: e.kind, a: e.a, b: e.b, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
});
}
/** Rayon d'un nœud : grossit doucement avec son nombre de liens. */
radiusOf(node: GraphNode): number {
return 14 + Math.min(10, node.degree * 1.5);
}
// --- Interactions (drag pour réarranger, clic pour ouvrir) ----------------
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
const svg = this.svgEl?.nativeElement;
if (!svg) return { x: evt.clientX, y: evt.clientY };
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: evt.clientX, y: evt.clientY };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
onPointerDown(evt: PointerEvent, node: GraphNode): void {
if (evt.button !== 0) return;
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingId = node.id;
this.dragOffsetX = x - node.x;
this.dragOffsetY = y - node.y;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
onPointerMove(evt: PointerEvent): void {
if (!this.draggingId) return;
const node = this.nodes.find(n => n.id === this.draggingId);
if (!node) return;
const { x, y } = this.toSvgCoords(evt);
const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX);
const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY);
if (!this.dragMoved) {
if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return;
this.dragMoved = true;
}
node.x = newX;
node.y = newY;
this.recomputeEdges();
this.fitSvgToNodes();
}
onPointerUp(evt: PointerEvent): void {
if (!this.draggingId) return;
const id = this.draggingId;
const moved = this.dragMoved;
this.draggingId = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
if (moved) {
this.scheduleSave();
return;
}
// Clic simple → ouvre la fiche de l'entité (page, PNJ, scène, quête).
const node = this.nodes.find(n => n.id === id);
if (node) this.router.navigate(node.route);
}
/** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */
private fitSvgToNodes(): void {
for (const n of this.nodes) {
if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN;
if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN;
}
}
/**
* Découpe un libellé en 1 à 2 lignes de {@link MAX_LABEL_CHARS} max (coupure aux
* mots) : « 1 - Le convoi de marchands » reste lisible au lieu d'être tronqué.
*/
private splitLabel(text: string): string[] {
const max = this.MAX_LABEL_CHARS;
if (text.length <= max) return [text];
let line1 = '';
for (const word of text.split(/\s+/)) {
const candidate = line1 ? `${line1} ${word}` : word;
if (candidate.length > max) break;
line1 = candidate;
}
if (!line1) line1 = text.slice(0, max); // premier mot plus long que la ligne
let rest = text.slice(line1.length).trim();
if (!rest) return [line1];
if (rest.length > max) rest = rest.slice(0, max - 1) + '…';
return [line1, rest];
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
ngOnDestroy(): void {
// Une sauvegarde en attente part immédiatement (sinon le drag serait perdu).
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.savePositions();
}
// Sidebar : volontairement rien — le composant suivant appellera show().
// Eviter d'appeler hide() ici previent le clignotement.
}
}

View File

@@ -1,4 +1,5 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, OnInit, Output, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -53,7 +54,8 @@ export class CampaignCreateComponent implements OnInit {
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
private loreService: LoreService, private loreService: LoreService,
private gameSystemService: GameSystemService private gameSystemService: GameSystemService,
private destroyRef: DestroyRef
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
name: ['', Validators.required], name: ['', Validators.required],
@@ -77,7 +79,7 @@ export class CampaignCreateComponent implements OnInit {
// Detecte la selection de l'option sentinelle "Creer un systeme" et bascule // Detecte la selection de l'option sentinelle "Creer un systeme" et bascule
// en mode creation inline. On reinitialise immediatement le control a '' // en mode creation inline. On reinitialise immediatement le control a ''
// pour que la sentinelle ne reste pas en valeur reelle du form. // pour que la sentinelle ne reste pas en valeur reelle du form.
this.form.get('gameSystemId')?.valueChanges.subscribe(value => { this.form.get('gameSystemId')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
if (value === this.CREATE_GAMESYSTEM_SENTINEL) { if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
this.form.get('gameSystemId')?.setValue('', { emitEvent: false }); this.form.get('gameSystemId')?.setValue('', { emitEvent: false });
this.startCreateGameSystem(); this.startCreateGameSystem();

View File

@@ -35,6 +35,11 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
[title]="'campaignDetail.graphTitle' | translate">
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
{{ 'campaignDetail.graph' | translate }}
</button>
<button type="button" class="btn-secondary" (click)="exportPdf()" [disabled]="exportingPdf"> <button type="button" class="btn-secondary" (click)="exportPdf()" [disabled]="exportingPdf">
<lucide-icon [img]="FileText" [size]="14"></lucide-icon> <lucide-icon [img]="FileText" [size]="14"></lucide-icon>
{{ (exportingPdf ? 'campaignDetail.pdfExporting' : 'campaignDetail.pdfExport') | translate }} {{ (exportingPdf ? 'campaignDetail.pdfExporting' : 'campaignDetail.pdfExport') | translate }}

View File

@@ -6,7 +6,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X } from 'lucide-angular'; import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X, Network } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { forkJoin, of, Observable } from 'rxjs'; import { forkJoin, of, Observable } from 'rxjs';
@@ -57,6 +57,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly ChevronDown = ChevronDown; readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight; readonly ChevronRight = ChevronRight;
readonly X = X; readonly X = X;
readonly Network = Network;
/** Export Foundry en cours (anti double-clic). */ /** Export Foundry en cours (anti double-clic). */
exportingFoundry = false; exportingFoundry = false;
@@ -416,6 +417,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
}); });
} }
/** Ouvre le graphe des liens : PNJ de la campagne reliés aux pages de son Lore. */
openGraph(): void {
if (this.campaign?.id) {
this.router.navigate(['/campaigns', this.campaign.id, 'graph']);
}
}
/** Génère et télécharge le livret PDF de la campagne. */ /** Génère et télécharge le livret PDF de la campagne. */
exportPdf(): void { exportPdf(): void {
if (!this.campaign?.id || this.exportingPdf) return; if (!this.campaign?.id || this.exportingPdf) return;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -114,7 +115,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
name: ['', Validators.required], name: ['', Validators.required],
@@ -131,7 +133,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!; const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!; const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!; const newChapterId = pm.get('chapterId')!;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener } from '@angular/core'; import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -111,11 +112,12 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private enemyService: EnemyService, private enemyService: EnemyService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
this.campaignId = pm.get('campaignId')!; this.campaignId = pm.get('campaignId')!;
this.arcId = pm.get('arcId')!; this.arcId = pm.get('arcId')!;
this.chapterId = pm.get('chapterId')!; this.chapterId = pm.get('chapterId')!;
@@ -130,7 +132,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId), scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => { }).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter; this.chapter = chapter;
this.scenes = scenes; this.scenes = scenes;
// Mode plat = campagne simple (1 arc, 1 chapitre) SANS compter la plomberie // Mode plat = campagne simple (1 arc, 1 chapitre) SANS compter la plomberie

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -54,11 +55,12 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!; const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!; const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!; const newChapterId = pm.get('chapterId')!;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -66,11 +67,12 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const cid = pm.get('campaignId')!; const cid = pm.get('campaignId')!;
const pid = pm.get('playthroughId')!; const pid = pm.get('playthroughId')!;
if (cid !== this.campaignId || pid !== this.playthroughId) { if (cid !== this.campaignId || pid !== this.playthroughId) {

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -42,11 +43,12 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
private playthroughService: PlaythroughService, private playthroughService: PlaythroughService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const cid = pm.get('campaignId')!; const cid = pm.get('campaignId')!;
const pid = pm.get('playthroughId')!; const pid = pm.get('playthroughId')!;
if (cid !== this.campaignId || pid !== this.playthroughId) { if (cid !== this.campaignId || pid !== this.playthroughId) {

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -106,7 +107,8 @@ export class QuestEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
name: ['', Validators.required], name: ['', Validators.required],
@@ -118,7 +120,7 @@ export class QuestEditComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
this.campaignId = pm.get('campaignId')!; this.campaignId = pm.get('campaignId')!;
this.questId = pm.get('questId'); // null sur la route /create this.questId = pm.get('questId'); // null sur la route /create
this.arcIdParam = this.route.snapshot.queryParamMap.get('arcId'); // ?arcId= depuis un arc HUB this.arcIdParam = this.route.snapshot.queryParamMap.get('arcId'); // ?arcId= depuis un arc HUB

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
@@ -66,11 +67,12 @@ export class QuestViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!; const newCampaignId = pm.get('campaignId')!;
const newQuestId = pm.get('questId')!; const newQuestId = pm.get('questId')!;
if (newQuestId !== this.questId || newCampaignId !== this.campaignId) { if (newQuestId !== this.questId || newCampaignId !== this.campaignId) {

View File

@@ -1,4 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular';
@@ -35,11 +36,12 @@ export class RandomTableViewComponent implements OnInit {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private service: RandomTableService, private service: RandomTableService,
private campaignSidebar: CampaignSidebarService private campaignSidebar: CampaignSidebarService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId'); const newCampaignId = pm.get('campaignId');
const newTableId = pm.get('tableId'); const newTableId = pm.get('tableId');
if (newCampaignId === this.campaignId && newTableId === this.tableId) return; if (newCampaignId === this.campaignId && newTableId === this.tableId) return;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -169,7 +170,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
name: ['', Validators.required], name: ['', Validators.required],
@@ -195,7 +197,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular // On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
// réutilise le composant quand on navigue entre scènes frères via l'arbre // réutilise le composant quand on navigue entre scènes frères via l'arbre
// (même route pattern), et ngOnInit ne se relance pas. // (même route pattern), et ngOnInit ne se relance pas.
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!; const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!; const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!; const newChapterId = pm.get('chapterId')!;
@@ -226,7 +228,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]); const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid }))); return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
}) }),
takeUntilDestroyed(this.destroyRef)
).subscribe(({ campaign, allCampaigns, scene, chapterScenes, treeData, pages, loreId }) => { ).subscribe(({ campaign, allCampaigns, scene, chapterScenes, treeData, pages, loreId }) => {
this.scene = scene; this.scene = scene;
this.pageTitleService.set(scene.name); this.pageTitleService.set(scene.name);

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -57,11 +58,12 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!; const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!; const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!; const newChapterId = pm.get('chapterId')!;

View File

@@ -8,11 +8,6 @@
<p class="description">{{ lore.description }}</p> <p class="description">{{ lore.description }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
[title]="'loreDetail.graphTitle' | translate">
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
{{ 'loreDetail.graph' | translate }}
</button>
<button type="button" class="btn-secondary" (click)="startEdit()" [title]="'loreDetail.editTitle' | translate"> <button type="button" class="btn-secondary" (click)="startEdit()" [title]="'loreDetail.editTitle' | translate">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> <lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
{{ 'common.edit' | translate }} {{ 'common.edit' | translate }}

View File

@@ -1,8 +1,9 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, Folder, Plus, Pencil, Trash2, Network } from 'lucide-angular'; import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LoreService } from '../../services/lore.service'; import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service'; import { TemplateService } from '../../services/template.service';
@@ -25,7 +26,6 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
readonly Plus = Plus; readonly Plus = Plus;
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Network = Network;
lore: Lore | null = null; lore: Lore | null = null;
/** Tous les dossiers du Lore (racines + enfants). */ /** Tous les dossiers du Lore (racines + enfants). */
@@ -47,14 +47,15 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
// On s'abonne à paramMap (pas snapshot) pour recharger quand on switche // On s'abonne à paramMap (pas snapshot) pour recharger quand on switche
// d'un Lore à l'autre via la liste globale de la sidebar — Angular réutilise // d'un Lore à l'autre via la liste globale de la sidebar — Angular réutilise
// le même composant et ngOnInit ne se relance pas tout seul. // le même composant et ngOnInit ne se relance pas tout seul.
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const id = pm.get('id'); const id = pm.get('id');
if (id && id !== this.lore?.id) { if (id && id !== this.lore?.id) {
this.load(id); this.load(id);
@@ -85,13 +86,6 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]); this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
} }
/** Ouvre la vue graphe : pages du Lore + PNJ liés, reliés par leurs liens. */
openGraph(): void {
if (this.lore?.id) {
this.router.navigate(['/lore', this.lore.id, 'graph']);
}
}
// ─────────────── Édition / suppression du Lore ─────────────── // ─────────────── Édition / suppression du Lore ───────────────
startEdit(): void { startEdit(): void {

View File

@@ -1,69 +0,0 @@
@if (lore) {
<div class="graph-page">
<header class="graph-header">
<button type="button" class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
{{ 'loreGraph.back' | translate }}
</button>
<div class="graph-title">
<h1>
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
{{ 'loreGraph.title' | translate:{ name: lore.name } }}
</h1>
<p class="graph-subtitle">
{{ 'loreGraph.subtitle' | translate:{ pages: (nodes.length - npcCount), npcs: npcCount, edges: edgeCount } }}
</p>
</div>
<div class="graph-legend">
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> {{ 'loreGraph.legendPage' | translate }}</span>
<span class="legend-item"><span class="legend-dot legend-dot--npc"></span> {{ 'loreGraph.legendNpc' | translate }}</span>
<span class="legend-item"><span class="legend-line legend-line--npc"></span> {{ 'loreGraph.legendLink' | translate }}</span>
</div>
</header>
@if (nodes.length === 0) {
<div class="graph-empty">
{{ 'loreGraph.empty' | translate }}
</div>
} @else {
<div class="graph-scroll">
<svg #svgEl
[attr.width]="svgWidth"
[attr.height]="svgHeight"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointerleave)="onPointerUp($event)">
<!-- Arêtes (sous les nœuds) -->
@for (e of edges; track e.key) {
<line
[attr.x1]="e.x1" [attr.y1]="e.y1"
[attr.x2]="e.x2" [attr.y2]="e.y2"
[class.edge-page]="e.kind === 'page'"
[class.edge-npc]="e.kind === 'npc'" />
}
<!-- Nœuds -->
@for (n of nodes; track n.id) {
<g class="node"
[class.node--npc]="n.kind === 'npc'"
[class.dragging]="draggingId === n.id"
(pointerdown)="onPointerDown($event, n)">
<circle [attr.cx]="n.x" [attr.cy]="n.y" [attr.r]="radiusOf(n)" />
<text class="node-label"
[attr.x]="n.x"
[attr.y]="n.y + radiusOf(n) + 14"
text-anchor="middle">{{ n.displayLabel }}</text>
<title>{{ n.label }}</title>
</g>
}
</svg>
</div>
@if (edgeCount === 0) {
<p class="graph-hint">
{{ 'loreGraph.hint' | translate }}
</p>
}
}
</div>
}

View File

@@ -1,352 +0,0 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft, Network } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { NpcService } from '../../services/npc.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Lore } from '../../services/lore.model';
import { Page } from '../../services/page.model';
import { Npc } from '../../services/npc.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/** Nœud du graphe : une page de Lore ou un PNJ qui référence des pages. */
interface GraphNode {
id: string; // 'page:<id>' ou 'npc:<id>' (évite les collisions d'IDs)
kind: 'page' | 'npc';
label: string;
displayLabel: string;
route: string[]; // navigation au clic
x: number; // centre du nœud (coords SVG)
y: number;
degree: number; // nombre de liens (taille du nœud)
}
interface GraphEdge {
key: string;
kind: 'page' | 'npc'; // page↔page ou npc→page (style distinct)
x1: number; y1: number; x2: number; y2: number;
}
/**
* Graphe du Lore : vue d'ensemble des pages et de leurs liens.
*
* Nœuds = toutes les pages du Lore + les PNJ (toutes campagnes liées au Lore)
* qui référencent au moins une page. Arêtes = `relatedPageIds` des pages
* (liens page↔page) et des PNJ (liens PNJ→page).
*
* Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au
* chargement, puis nœuds déplaçables à la souris — même approche SVG custom
* que chapter-graph, sans dépendance externe.
*/
@Component({
selector: 'app-lore-graph',
imports: [RouterModule, LucideAngularModule, TranslatePipe],
templateUrl: './lore-graph.component.html',
styleUrls: ['./lore-graph.component.scss']
})
export class LoreGraphComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Network = Network;
loreId = '';
lore: Lore | null = null;
nodes: GraphNode[] = [];
edges: GraphEdge[] = [];
npcCount = 0;
edgeCount = 0;
readonly MAX_LABEL_CHARS = 22;
private readonly MARGIN = 70;
svgWidth = 800;
svgHeight = 600;
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
draggingId: string | null = null;
private dragOffsetX = 0;
private dragOffsetY = 0;
private dragMoved = false;
private readonly DRAG_THRESHOLD = 4;
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
private adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private npcService: NpcService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private translate: TranslateService
) {}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
npcs: this.npcService.getByLore(this.loreId)
}).subscribe(({ sidebar, npcs }) => {
this.lore = sidebar.lore;
this.layoutService.show(buildLoreSidebarConfig(sidebar));
this.pageTitleService.set(this.translate.instant('loreGraph.title', { name: sidebar.lore.name }));
this.buildGraph(sidebar.pages, npcs);
});
}
// --- Construction du graphe ----------------------------------------------
private buildGraph(pages: Page[], npcs: Npc[]): void {
const pageIds = new Set(pages.map(p => p.id!));
// Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines).
const nodes: GraphNode[] = pages.map(p => ({
id: `page:${p.id}`,
kind: 'page' as const,
label: p.title,
displayLabel: this.truncate(p.title),
route: ['/lore', this.loreId, 'pages', p.id!],
x: 0, y: 0, degree: 0
}));
// Nœuds PNJ : seulement ceux qui référencent au moins une page de CE lore
// (un PNJ sans lien n'apporte rien à la carte des connexions).
const linkedNpcs = npcs.filter(n =>
(n.relatedPageIds ?? []).some(pid => pageIds.has(pid)));
for (const n of linkedNpcs) {
nodes.push({
id: `npc:${n.id}`,
kind: 'npc',
label: n.name,
displayLabel: this.truncate(n.name),
route: ['/campaigns', n.campaignId, 'npcs', n.id!],
x: 0, y: 0, degree: 0
});
}
this.npcCount = linkedNpcs.length;
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
// (A→B et B→A = un seul trait).
const adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
const seenPairs = new Set<string>();
for (const p of pages) {
for (const targetId of p.relatedPageIds ?? []) {
if (!pageIds.has(targetId) || targetId === p.id) continue;
const pair = [p.id!, targetId].sort().join('|');
if (seenPairs.has(pair)) continue;
seenPairs.add(pair);
adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` });
}
}
for (const n of linkedNpcs) {
for (const targetId of new Set(n.relatedPageIds ?? [])) {
if (!pageIds.has(targetId)) continue;
adjacency.push({ key: `np:${n.id}|${targetId}`, kind: 'npc', a: `npc:${n.id}`, b: `page:${targetId}` });
}
}
this.adjacency = adjacency;
this.edgeCount = adjacency.length;
// Degré (pondère la taille des nœuds : une capitale très liée ressort).
const degree = new Map<string, number>();
for (const e of adjacency) {
degree.set(e.a, (degree.get(e.a) ?? 0) + 1);
degree.set(e.b, (degree.get(e.b) ?? 0) + 1);
}
for (const node of nodes) {
node.degree = degree.get(node.id) ?? 0;
}
this.nodes = nodes;
this.runForceLayout();
this.recomputeEdges();
}
/**
* Layout force-directed (Fruchterman-Reingold simplifié) :
* répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le
* centre (regroupe les composantes déconnectées), refroidissement progressif.
* Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf.
* convention projet d'éviter Math.random pour des rendus reproductibles).
*/
private runForceLayout(): void {
const n = this.nodes.length;
if (n === 0) {
this.svgWidth = 800; this.svgHeight = 400;
return;
}
const side = Math.max(600, Math.ceil(170 * Math.sqrt(n)));
const w = side, h = side;
const cx = w / 2, cy = h / 2;
// Init en spirale : angle d'or → répartition uniforme et déterministe.
const golden = Math.PI * (3 - Math.sqrt(5));
this.nodes.forEach((node, i) => {
const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN);
const a = i * golden;
node.x = cx + r * Math.cos(a);
node.y = cy + r * Math.sin(a);
});
const index = new Map(this.nodes.map(node => [node.id, node]));
const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds
let temperature = side / 8;
for (let iter = 0; iter < 300; iter++) {
const dx = new Map<string, number>();
const dy = new Map<string, number>();
for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); }
// Répulsion entre toutes les paires.
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = this.nodes[i], b = this.nodes[j];
let vx = a.x - b.x, vy = a.y - b.y;
let d = Math.hypot(vx, vy);
if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); }
const force = (k * k) / d;
dx.set(a.id, dx.get(a.id)! + (vx / d) * force);
dy.set(a.id, dy.get(a.id)! + (vy / d) * force);
dx.set(b.id, dx.get(b.id)! - (vx / d) * force);
dy.set(b.id, dy.get(b.id)! - (vy / d) * force);
}
}
// Attraction le long des arêtes.
for (const e of this.adjacency) {
const a = index.get(e.a)!, b = index.get(e.b)!;
const vx = a.x - b.x, vy = a.y - b.y;
const d = Math.max(0.01, Math.hypot(vx, vy));
const force = (d * d) / k;
dx.set(a.id, dx.get(a.id)! - (vx / d) * force);
dy.set(a.id, dy.get(a.id)! - (vy / d) * force);
dx.set(b.id, dx.get(b.id)! + (vx / d) * force);
dy.set(b.id, dy.get(b.id)! + (vy / d) * force);
}
// Gravité douce vers le centre (sinon les composantes isolées fuient).
for (const node of this.nodes) {
dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06);
dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06);
}
// Application bornée par la température, dans le cadre.
for (const node of this.nodes) {
const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!;
const d = Math.max(0.01, Math.hypot(ddx, ddy));
const step = Math.min(d, temperature);
node.x = Math.min(w - this.MARGIN, Math.max(this.MARGIN, node.x + (ddx / d) * step));
node.y = Math.min(h - this.MARGIN, Math.max(this.MARGIN, node.y + (ddy / d) * step));
}
temperature *= 0.96;
}
this.svgWidth = w;
this.svgHeight = h;
}
/** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */
private recomputeEdges(): void {
const index = new Map(this.nodes.map(n => [n.id, n]));
this.edges = this.adjacency
.filter(e => index.has(e.a) && index.has(e.b))
.map(e => {
const a = index.get(e.a)!, b = index.get(e.b)!;
return { key: e.key, kind: e.kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
});
}
/** Rayon d'un nœud : grossit doucement avec son nombre de liens. */
radiusOf(node: GraphNode): number {
return 14 + Math.min(10, node.degree * 1.5);
}
// --- Interactions (drag pour réarranger, clic pour ouvrir) ----------------
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
const svg = this.svgEl?.nativeElement;
if (!svg) return { x: evt.clientX, y: evt.clientY };
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: evt.clientX, y: evt.clientY };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
onPointerDown(evt: PointerEvent, node: GraphNode): void {
if (evt.button !== 0) return;
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingId = node.id;
this.dragOffsetX = x - node.x;
this.dragOffsetY = y - node.y;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
onPointerMove(evt: PointerEvent): void {
if (!this.draggingId) return;
const node = this.nodes.find(n => n.id === this.draggingId);
if (!node) return;
const { x, y } = this.toSvgCoords(evt);
const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX);
const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY);
if (!this.dragMoved) {
if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return;
this.dragMoved = true;
}
node.x = newX;
node.y = newY;
this.recomputeEdges();
this.fitSvgToNodes();
}
onPointerUp(evt: PointerEvent): void {
if (!this.draggingId) return;
const id = this.draggingId;
const moved = this.dragMoved;
this.draggingId = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
if (moved) return;
// Clic simple → ouvre la page / la fiche PNJ.
const node = this.nodes.find(n => n.id === id);
if (node) this.router.navigate(node.route);
}
/** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */
private fitSvgToNodes(): void {
for (const n of this.nodes) {
if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN;
if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN;
}
}
private truncate(text: string): string {
return text.length > this.MAX_LABEL_CHARS
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
: text;
}
back(): void {
this.router.navigate(['/lore', this.loreId]);
}
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement.
}
}

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -62,7 +63,8 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
private templateService: TemplateService, private templateService: TemplateService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService private pageTitleService: PageTitleService,
private destroyRef: DestroyRef
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
name: ['', Validators.required], name: ['', Validators.required],
@@ -76,7 +78,7 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
// Réagir aux changements de :folderId (navigation entre dossiers dans la sidebar // Réagir aux changements de :folderId (navigation entre dossiers dans la sidebar
// sans démonter le composant). // sans démonter le composant).
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newId = pm.get('folderId')!; const newId = pm.get('folderId')!;
if (newId !== this.folderId) { if (newId !== this.folderId) {
this.folderId = newId; this.folderId = newId;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -67,7 +68,8 @@ export class PageCreateComponent implements OnInit, OnDestroy {
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
title: ['', Validators.required], title: ['', Validators.required],
@@ -116,7 +118,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
} }
} }
this.form.get('nodeId')?.valueChanges.subscribe(nodeId => { this.form.get('nodeId')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(nodeId => {
this.autoSelectTemplateForNode(nodeId); this.autoSelectTemplateForNode(nodeId);
}); });

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -109,7 +110,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) { ) {
this.chatPrimaryAction = { label: this.translate.instant('pageEdit.chatPrimaryAction') }; this.chatPrimaryAction = { label: this.translate.instant('pageEdit.chatPrimaryAction') };
this.chatQuickSuggestions = [ this.chatQuickSuggestions = [
@@ -126,7 +128,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
// navigue d'une page à une autre (ex. via les chips du lore-link-picker), // navigue d'une page à une autre (ex. via les chips du lore-link-picker),
// Angular réutilise le composant et ngOnInit ne se relance pas → l'écran // Angular réutilise le composant et ngOnInit ne se relance pas → l'écran
// resterait figé sur l'ancienne page. // resterait figé sur l'ancienne page.
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newPageId = pm.get('pageId')!; const newPageId = pm.get('pageId')!;
if (newPageId && newPageId !== this.pageId) { if (newPageId && newPageId !== this.pageId) {
this.pageId = newPageId; this.pageId = newPageId;

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -65,14 +66,15 @@ export class PageViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!; this.loreId = this.route.snapshot.paramMap.get('loreId')!;
// Même pattern que page-edit : on s'abonne à paramMap pour gérer la // Même pattern que page-edit : on s'abonne à paramMap pour gérer la
// navigation d'une page à l'autre (Angular réutilise le composant). // navigation d'une page à l'autre (Angular réutilise le composant).
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newPageId = pm.get('pageId')!; const newPageId = pm.get('pageId')!;
if (newPageId && newPageId !== this.pageId) { if (newPageId && newPageId !== this.pageId) {
this.pageId = newPageId; this.pageId = newPageId;

View File

@@ -10,6 +10,8 @@ export interface Campaign {
loreId?: string | null; loreId?: string | null;
/** ID du GameSystem associé (weak reference cross-context). `null` = campagne générique. */ /** ID du GameSystem associé (weak reference cross-context). `null` = campagne générique. */
gameSystemId?: string | null; gameSystemId?: string | null;
/** Positions des nœuds du graphe de campagne (JSON `"<kind>:<id>" -> {x,y}`). Null = auto. */
graphPositions?: string | null;
} }
// Interface pour la création de Campaign (sans id) // Interface pour la création de Campaign (sans id)

View File

@@ -73,6 +73,11 @@ export class CampaignService {
return this.http.get<Campaign>(`${this.apiUrl}/${id}`); return this.http.get<Campaign>(`${this.apiUrl}/${id}`);
} }
/** Sauvegarde la disposition du graphe de campagne (JSON opaque ; '' = disposition auto). */
updateGraphPositions(id: string, positionsJson: string): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/${id}/graph-positions`, { positions: positionsJson });
}
/** Bilan de préparation (Pilier B) — alimente les pastilles de l'arbre de la sidebar. */ /** Bilan de préparation (Pilier B) — alimente les pastilles de l'arbre de la sidebar. */
getReadiness(id: string): Observable<CampaignReadinessAssessment> { getReadiness(id: string): Observable<CampaignReadinessAssessment> {
return this.http.get<CampaignReadinessAssessment>(`${this.apiUrl}/${id}/readiness`); return this.http.get<CampaignReadinessAssessment>(`${this.apiUrl}/${id}/readiness`);

View File

@@ -16,11 +16,6 @@ export class NpcService {
return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`); return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`);
} }
/** PNJ de toutes les campagnes liées à un Lore — alimente le graphe du Lore. */
getByLore(loreId: string): Observable<Npc[]> {
return this.http.get<Npc[]>(`${this.apiUrl}/lore/${loreId}`);
}
getById(id: string): Observable<Npc> { getById(id: string): Observable<Npc> {
return this.http.get<Npc>(`${this.apiUrl}/${id}`); return this.http.get<Npc>(`${this.apiUrl}/${id}`);
} }

View File

@@ -198,8 +198,6 @@
"submit": "Create lore" "submit": "Create lore"
}, },
"loreDetail": { "loreDetail": {
"graph": "Graph",
"graphTitle": "View the graph of pages and their links (NPCs included)",
"editTitle": "Edit Lore", "editTitle": "Edit Lore",
"deleteTitle": "Delete Lore", "deleteTitle": "Delete Lore",
"folders": "Folders", "folders": "Folders",
@@ -221,14 +219,19 @@
"irreversible": "This action is irreversible." "irreversible": "This action is irreversible."
} }
}, },
"loreGraph": { "campaignGraph": {
"back": "Back to Lore", "back": "Back to campaign",
"title": "{{name}} — Graph", "title": "{{name}} — Graph",
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{edges}} link(s). Click a node to open it, drag it to rearrange.", "subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{scenes}} scene(s) · {{quests}} quest(s) · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
"legendPage": "Lore page", "legendPage": "Lore page",
"legendNpc": "NPC", "legendNpc": "NPC",
"legendLink": "NPC → page link", "legendScene": "Scene",
"empty": "No pages in this Lore yet — the graph will fill in as you go.", "legendQuest": "Quest",
"reset": "Auto layout",
"resetTitle": "Recompute the automatic layout (forgets your manual moves)",
"toggleHint": "Click to hide / show this entity type",
"noLore": "No world (Lore) is linked to this campaign: link one from the campaign header to see the graph.",
"empty": "No pages in the linked world yet — the graph will fill in as you go.",
"hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet." "hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet."
}, },
"loreNodeCreate": { "loreNodeCreate": {
@@ -613,6 +616,8 @@
"foundryExporting": "Exporting…", "foundryExporting": "Exporting…",
"pdfExport": "Export to PDF", "pdfExport": "Export to PDF",
"pdfExporting": "Generating…", "pdfExporting": "Generating…",
"graph": "Graph",
"graphTitle": "View the graph of NPCs and their linked lore pages",
"gameSystemChange": { "gameSystemChange": {
"title": "Change the game system?", "title": "Change the game system?",
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.", "message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",

View File

@@ -198,8 +198,6 @@
"submit": "Créer le lore" "submit": "Créer le lore"
}, },
"loreDetail": { "loreDetail": {
"graph": "Graphe",
"graphTitle": "Visualiser le graphe des pages et de leurs liens (PNJ inclus)",
"editTitle": "Modifier le Lore", "editTitle": "Modifier le Lore",
"deleteTitle": "Supprimer le Lore", "deleteTitle": "Supprimer le Lore",
"folders": "Dossiers", "folders": "Dossiers",
@@ -221,14 +219,19 @@
"irreversible": "Cette action est irréversible." "irreversible": "Cette action est irréversible."
} }
}, },
"loreGraph": { "campaignGraph": {
"back": "Retour au Lore", "back": "Retour à la campagne",
"title": "{{name}} — Graphe", "title": "{{name}} — Graphe",
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.", "subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{scenes}} scène(s) · {{quests}} quête(s) · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
"legendPage": "Page de Lore", "legendPage": "Page de Lore",
"legendNpc": "PNJ", "legendNpc": "PNJ",
"legendLink": "Lien PNJ → page", "legendScene": "Scène",
"empty": "Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.", "legendQuest": "Quête",
"reset": "Disposition auto",
"resetTitle": "Recalculer la disposition automatique (oublie vos déplacements manuels)",
"toggleHint": "Cliquer pour masquer / afficher ce type d'entité",
"noLore": "Aucun univers (Lore) n'est associé à cette campagne : associez-en un depuis l'en-tête de la campagne pour voir le graphe.",
"empty": "Aucune page dans l'univers associé pour l'instant — le graphe se remplira au fur et à mesure.",
"hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche." "hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche."
}, },
"loreNodeCreate": { "loreNodeCreate": {
@@ -613,6 +616,8 @@
"foundryExporting": "Export…", "foundryExporting": "Export…",
"pdfExport": "Exporter en PDF", "pdfExport": "Exporter en PDF",
"pdfExporting": "Génération…", "pdfExporting": "Génération…",
"graph": "Graphe",
"graphTitle": "Visualiser le graphe des PNJ et des pages de lore liées",
"gameSystemChange": { "gameSystemChange": {
"title": "Changer le système de jeu ?", "title": "Changer le système de jeu ?",
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.", "message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",

View File

@@ -81,6 +81,8 @@
"foundryExporting": "Exporting…", "foundryExporting": "Exporting…",
"pdfExport": "Export to PDF", "pdfExport": "Export to PDF",
"pdfExporting": "Generating…", "pdfExporting": "Generating…",
"graph": "Graph",
"graphTitle": "View the graph of NPCs and their linked lore pages",
"gameSystemChange": { "gameSystemChange": {
"title": "Change the game system?", "title": "Change the game system?",
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.", "message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",
@@ -104,6 +106,21 @@
"irreversible": "This action is irreversible." "irreversible": "This action is irreversible."
} }
}, },
"campaignGraph": {
"back": "Back to campaign",
"title": "{{name}} — Graph",
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{scenes}} scene(s) · {{quests}} quest(s) · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
"legendPage": "Lore page",
"legendNpc": "NPC",
"legendScene": "Scene",
"legendQuest": "Quest",
"reset": "Auto layout",
"resetTitle": "Recompute the automatic layout (forgets your manual moves)",
"toggleHint": "Click to hide / show this entity type",
"noLore": "No world (Lore) is linked to this campaign: link one from the campaign header to see the graph.",
"empty": "No pages in the linked world yet — the graph will fill in as you go.",
"hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet."
},
"campaignImport": { "campaignImport": {
"pageTitle": "Import a campaign", "pageTitle": "Import a campaign",
"back": "Back to the campaign", "back": "Back to the campaign",

View File

@@ -81,6 +81,8 @@
"foundryExporting": "Export…", "foundryExporting": "Export…",
"pdfExport": "Exporter en PDF", "pdfExport": "Exporter en PDF",
"pdfExporting": "Génération…", "pdfExporting": "Génération…",
"graph": "Graphe",
"graphTitle": "Visualiser le graphe des PNJ et des pages de lore liées",
"gameSystemChange": { "gameSystemChange": {
"title": "Changer le système de jeu ?", "title": "Changer le système de jeu ?",
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.", "message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",
@@ -104,6 +106,21 @@
"irreversible": "Cette action est irréversible." "irreversible": "Cette action est irréversible."
} }
}, },
"campaignGraph": {
"back": "Retour à la campagne",
"title": "{{name}} — Graphe",
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{scenes}} scène(s) · {{quests}} quête(s) · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
"legendPage": "Page de Lore",
"legendNpc": "PNJ",
"legendScene": "Scène",
"legendQuest": "Quête",
"reset": "Disposition auto",
"resetTitle": "Recalculer la disposition automatique (oublie vos déplacements manuels)",
"toggleHint": "Cliquer pour masquer / afficher ce type d'entité",
"noLore": "Aucun univers (Lore) n'est associé à cette campagne : associez-en un depuis l'en-tête de la campagne pour voir le graphe.",
"empty": "Aucune page dans l'univers associé pour l'instant — le graphe se remplira au fur et à mesure.",
"hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche."
},
"campaignImport": { "campaignImport": {
"pageTitle": "Importer une campagne", "pageTitle": "Importer une campagne",
"back": "Retour à la campagne", "back": "Retour à la campagne",

View File

@@ -45,8 +45,6 @@
"submit": "Create lore" "submit": "Create lore"
}, },
"loreDetail": { "loreDetail": {
"graph": "Graph",
"graphTitle": "View the graph of pages and their links (NPCs included)",
"editTitle": "Edit Lore", "editTitle": "Edit Lore",
"deleteTitle": "Delete Lore", "deleteTitle": "Delete Lore",
"folders": "Folders", "folders": "Folders",
@@ -68,16 +66,6 @@
"irreversible": "This action is irreversible." "irreversible": "This action is irreversible."
} }
}, },
"loreGraph": {
"back": "Back to Lore",
"title": "{{name}} — Graph",
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
"legendPage": "Lore page",
"legendNpc": "NPC",
"legendLink": "NPC → page link",
"empty": "No pages in this Lore yet — the graph will fill in as you go.",
"hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet."
},
"loreNodeCreate": { "loreNodeCreate": {
"title": "Create a new folder", "title": "Create a new folder",
"subtitle": "Folders let you organize your pages by category", "subtitle": "Folders let you organize your pages by category",

View File

@@ -45,8 +45,6 @@
"submit": "Créer le lore" "submit": "Créer le lore"
}, },
"loreDetail": { "loreDetail": {
"graph": "Graphe",
"graphTitle": "Visualiser le graphe des pages et de leurs liens (PNJ inclus)",
"editTitle": "Modifier le Lore", "editTitle": "Modifier le Lore",
"deleteTitle": "Supprimer le Lore", "deleteTitle": "Supprimer le Lore",
"folders": "Dossiers", "folders": "Dossiers",
@@ -68,16 +66,6 @@
"irreversible": "Cette action est irréversible." "irreversible": "Cette action est irréversible."
} }
}, },
"loreGraph": {
"back": "Retour au Lore",
"title": "{{name}} — Graphe",
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
"legendPage": "Page de Lore",
"legendNpc": "PNJ",
"legendLink": "Lien PNJ → page",
"empty": "Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.",
"hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche."
},
"loreNodeCreate": { "loreNodeCreate": {
"title": "Créer un nouveau dossier", "title": "Créer un nouveau dossier",
"subtitle": "Les dossiers permettent d'organiser vos pages par catégorie", "subtitle": "Les dossiers permettent d'organiser vos pages par catégorie",