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
This commit is contained in:
2026-07-04 11:15:16 +02:00
parent b2c3800bf8
commit beafbc2fa9
69 changed files with 3720 additions and 1509 deletions

View File

@@ -92,6 +92,22 @@ public class CampaignService {
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) {
return (id == null || id.isBlank()) ? null : id;
}

View File

@@ -1,9 +1,7 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service;
@@ -20,11 +18,9 @@ import java.util.Optional;
public class NpcService {
private final NpcRepository npcRepository;
private final CampaignRepository campaignRepository;
public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) {
public NpcService(NpcRepository npcRepository) {
this.npcRepository = npcRepository;
this.campaignRepository = campaignRepository;
}
public record NpcData(
@@ -67,21 +63,6 @@ public class NpcService {
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) {
Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));

View File

@@ -38,6 +38,12 @@ public class Campaign {
*/
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() {
return this.loreId != null && !this.loreId.isBlank();
}

View File

@@ -55,6 +55,13 @@ public class CampaignJpaEntity {
@Column(name = "game_system_id")
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
protected void onCreate() {
createdAt = LocalDateTime.now();

View File

@@ -76,6 +76,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.playerCount(jpaEntity.getPlayerCount())
.loreId(jpaEntity.getLoreId())
.gameSystemId(jpaEntity.getGameSystemId())
.graphPositions(jpaEntity.getGraphPositions())
.build();
}
@@ -91,6 +92,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.playerCount(campaign.getPlayerCount())
.loreId(campaign.getLoreId())
.gameSystemId(campaign.getGameSystemId())
.graphPositions(campaign.getGraphPositions())
.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;
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 org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
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>
* 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
* String) sont remappees oldId -> newId. Cela permet d'agreger plusieurs exports
* dans une meme base sans collision, entre Postgres et H2.
* <p>
* Algorithme :
* Algorithme, chaque phase etant portee par un composant dedie :
* <ol>
* <li>Parse le zip : {@code data.json} -> {@link ContentExport}, binaires gardes en memoire.</li>
* <li>Reecrit les images sous LEUR CLE D'ORIGINE (pas de remapping de cle) ;
* skip si une ImageJpaEntity avec cette cle existe deja.</li>
* <li>Insere top-down en construisant les maps de remapping par type.</li>
* <li>2e passe : remappe les references qui pointent vers des types inserees
* plus tard (parentId, defaultNodeId, refs faibles String) puis re-save.</li>
* <li>{@link ImportArchiveParser} : {@code data.json} -> {@link ContentExport},
* binaires gardes en memoire.</li>
* <li>{@link ImageImporter} / {@link StoredFileImporter} : reecrit les binaires
* sous LEUR CLE D'ORIGINE (pas de remapping de cle) ; skip si la cle existe deja.</li>
* <li>Insertion top-down en construisant les maps de remapping par type
* ({@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>
* 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).
@@ -54,93 +34,34 @@ import java.util.zip.ZipInputStream;
@Service
public class ImportService {
// (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 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 ImportArchiveParser archiveParser;
private final ImageImporter imageImporter;
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,
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,
public ImportService(ImportArchiveParser archiveParser,
ImageImporter imageImporter,
StoredFileImporter storedFileImporter,
ObjectMapper objectMapper) {
this.gameSystemRepo = gameSystemRepo;
this.loreRepo = loreRepo;
this.loreNodeRepo = loreNodeRepo;
this.templateRepo = templateRepo;
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;
LoreContentInserter loreContentInserter,
CampaignContentInserter campaignContentInserter,
PlayStateInserter playStateInserter,
ImportReferenceRemapper referenceRemapper) {
this.archiveParser = archiveParser;
this.imageImporter = imageImporter;
this.storedFileImporter = storedFileImporter;
this.objectMapper = objectMapper;
this.loreContentInserter = loreContentInserter;
this.campaignContentInserter = campaignContentInserter;
this.playStateInserter = playStateInserter;
this.referenceRemapper = referenceRemapper;
}
@Transactional
public ImportResult importZip(InputStream zipStream) {
// 1. Parse du zip.
ParsedArchive archive = parseArchive(zipStream);
ImportArchiveParser.ParsedArchive archive = archiveParser.parse(zipStream);
ContentExport export = archive.export();
ImportResult.Builder result = new ImportResult.Builder();
@@ -150,659 +71,14 @@ public class ImportService {
storedFileImporter.importFiles(export, archive.fileBinaries(), result);
// 3. Insertion top-down + maps de remapping.
Map<Long, Long> gameSystemMap = new HashMap<>();
Map<Long, Long> loreMap = new HashMap<>();
Map<Long, Long> loreNodeMap = new HashMap<>();
Map<Long, Long> templateMap = new HashMap<>();
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<>();
ImportIdMaps maps = new ImportIdMaps();
loreContentInserter.insert(export, maps, result);
campaignContentInserter.insert(export, maps, result);
playStateInserter.insert(export, maps, 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());
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);
}
});
}
// 4. 2e passe de remapping.
referenceRemapper.remap(maps);
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);
}
/** 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}")
public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
Campaign updatedCampaign = campaignService.updateCampaign(

View File

@@ -52,15 +52,6 @@ public class NpcController {
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}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));

View File

@@ -19,4 +19,6 @@ public class CampaignDTO {
private String loreId;
/** Nullable : campagne sans système de JDR associé (générique). */
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.setLoreId(campaign.getLoreId());
dto.setGameSystemId(campaign.getGameSystemId());
dto.setGraphPositions(campaign.getGraphPositions());
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());
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());
}
@Test
void getByLore_returnsArray() throws Exception {
mockMvc.perform(get("/api/npcs/lore/{loreId}", "lore-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void update_returns200() throws Exception {
Npc saved = npcRepository.save(Npc.builder().campaignId(campaignId).name("old").order(0).build());