Compare commits
3 Commits
b2c3800bf8
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f72161e41e | |||
| 4d05aa5118 | |||
| beafbc2fa9 |
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="1.0.0-beta",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>1.0.0-beta</version>
|
||||
<version>1.0.0</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
BIN
core/src/main/resources/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
core/src/main/resources/fonts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
core/src/main/resources/fonts/DejaVuSans-BoldOblique.ttf
Normal file
BIN
core/src/main/resources/fonts/DejaVuSans-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
core/src/main/resources/fonts/DejaVuSans-Oblique.ttf
Normal file
BIN
core/src/main/resources/fonts/DejaVuSans-Oblique.ttf
Normal file
Binary file not shown.
BIN
core/src/main/resources/fonts/DejaVuSans.ttf
Normal file
BIN
core/src/main/resources/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
BIN
core/src/main/resources/fonts/DejaVuSerif-Bold.ttf
Normal file
BIN
core/src/main/resources/fonts/DejaVuSerif-Bold.ttf
Normal file
Binary file not shown.
BIN
core/src/main/resources/fonts/DejaVuSerif-BoldItalic.ttf
Normal file
BIN
core/src/main/resources/fonts/DejaVuSerif-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
core/src/main/resources/fonts/DejaVuSerif-Italic.ttf
Normal file
BIN
core/src/main/resources/fonts/DejaVuSerif-Italic.ttf
Normal file
Binary file not shown.
BIN
core/src/main/resources/fonts/DejaVuSerif.ttf
Normal file
BIN
core/src/main/resources/fonts/DejaVuSerif.ttf
Normal file
Binary file not shown.
187
core/src/main/resources/fonts/LICENSE-DejaVu.txt
Normal file
187
core/src/main/resources/fonts/LICENSE-DejaVu.txt
Normal 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$
|
||||
@@ -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", "{}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "10kb",
|
||||
"maximumWarning": "12kb",
|
||||
"maximumError": "16kb"
|
||||
}
|
||||
]
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "1.0.0-beta",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "1.0.0-beta",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.16",
|
||||
"@angular/cdk": "^21.2.14",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "1.0.0-beta",
|
||||
"version": "1.0.0",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build": "npm run i18n:check && ng build",
|
||||
"i18n:check": "node scripts/check-i18n.mjs",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"test:unit": "vitest run",
|
||||
|
||||
127
web/scripts/check-i18n.mjs
Normal file
127
web/scripts/check-i18n.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
// Vérificateur de cohérence i18n.
|
||||
//
|
||||
// Les traductions vivent en DOUBLE : les fichiers consolidés (src/assets/i18n/fr.json,
|
||||
// en.json — ceux que charge ngx-translate) et les fragments de maintenance
|
||||
// (src/assets/i18n/fragments/*.fr.json / *.en.json). Une section peut être alimentée
|
||||
// par PLUSIEURS fragments (ex. playthroughDetail) : un fragment est donc un
|
||||
// SOUS-ENSEMBLE du consolidé, pas une copie exacte.
|
||||
//
|
||||
// ERREURS (code retour 1) :
|
||||
// 1. clé de fragment absente du consolidé, ou valeur différente (le consolidé est
|
||||
// ce que voit l'utilisateur : un fragment en avance = trad perdue en prod) ;
|
||||
// 2. deux fragments qui définissent la même clé avec des valeurs contradictoires ;
|
||||
// 3. parité structurelle fr/en rompue (fr.json vs en.json, et chaque paire de
|
||||
// fragments x.fr.json / x.en.json) ;
|
||||
// 4. JSON invalide ou paire de fragments incomplète.
|
||||
//
|
||||
// AVERTISSEMENTS (non bloquants) : clés du consolidé couvertes par aucun fragment
|
||||
// (dette de maintenance, pas un bug utilisateur).
|
||||
//
|
||||
// Usage : node scripts/check-i18n.mjs
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const webDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const i18nDir = path.join(webDir, 'src', 'assets', 'i18n');
|
||||
const fragmentsDir = path.join(i18nDir, 'fragments');
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
function loadJson(file) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
errors.push(`${path.relative(webDir, file)} : JSON invalide (${e.message})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/** Feuilles d'un objet : Map "a.b.c" -> valeur (les tableaux sont des feuilles). */
|
||||
function leaves(obj, prefix = '', out = new Map()) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const p = prefix ? `${prefix}.${key}` : key;
|
||||
if (isObject(value)) leaves(value, p, out);
|
||||
else out.set(p, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
/** Parité structurelle : mêmes chemins de feuilles des deux côtés. */
|
||||
function structuralDiff(labelA, a, labelB, b) {
|
||||
const pathsA = leaves(a);
|
||||
const pathsB = leaves(b);
|
||||
for (const p of pathsA.keys()) if (!pathsB.has(p)) errors.push(`${labelB} : clé manquante « ${p} » (présente dans ${labelA})`);
|
||||
for (const p of pathsB.keys()) if (!pathsA.has(p)) errors.push(`${labelA} : clé manquante « ${p} » (présente dans ${labelB})`);
|
||||
}
|
||||
|
||||
// --- Chargement -------------------------------------------------------------
|
||||
const consolidated = {
|
||||
fr: loadJson(path.join(i18nDir, 'fr.json')),
|
||||
en: loadJson(path.join(i18nDir, 'en.json'))
|
||||
};
|
||||
const consolidatedLeaves = {
|
||||
fr: consolidated.fr ? leaves(consolidated.fr) : new Map(),
|
||||
en: consolidated.en ? leaves(consolidated.en) : new Map()
|
||||
};
|
||||
const fragmentFiles = readdirSync(fragmentsDir).filter(f => f.endsWith('.json')).sort();
|
||||
|
||||
// --- 1 + 2 : chaque clé de fragment doit exister À L'IDENTIQUE au consolidé ---
|
||||
const fragmentLeafOwner = { fr: new Map(), en: new Map() };
|
||||
for (const file of fragmentFiles) {
|
||||
const match = file.match(/^(.+)\.(fr|en)\.json$/);
|
||||
if (!match) { errors.push(`fragments/${file} : nom inattendu (attendu <nom>.fr.json / <nom>.en.json)`); continue; }
|
||||
const lang = match[2];
|
||||
const fragment = loadJson(path.join(fragmentsDir, file));
|
||||
if (!fragment || !consolidated[lang]) continue;
|
||||
for (const [leafPath, value] of leaves(fragment)) {
|
||||
const owner = fragmentLeafOwner[lang].get(leafPath);
|
||||
if (owner && !sameValue(owner.value, value)) {
|
||||
errors.push(`Clé « ${leafPath} » (${lang}) contradictoire entre fragments/${owner.file} et fragments/${file}`);
|
||||
}
|
||||
fragmentLeafOwner[lang].set(leafPath, { file, value });
|
||||
if (!consolidatedLeaves[lang].has(leafPath)) {
|
||||
errors.push(`${lang}.json : clé « ${leafPath} » manquante (définie par fragments/${file})`);
|
||||
} else if (!sameValue(consolidatedLeaves[lang].get(leafPath), value)) {
|
||||
errors.push(`${lang}.json ≠ fragments/${file} : valeur différente pour « ${leafPath} »`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3 : parité structurelle fr/en -------------------------------------------
|
||||
if (consolidated.fr && consolidated.en) structuralDiff('fr.json', consolidated.fr, 'en.json', consolidated.en);
|
||||
const fragmentNames = new Set(fragmentFiles.map(f => f.replace(/\.(fr|en)\.json$/, '')));
|
||||
for (const name of fragmentNames) {
|
||||
const frFile = `${name}.fr.json`;
|
||||
const enFile = `${name}.en.json`;
|
||||
if (!fragmentFiles.includes(frFile) || !fragmentFiles.includes(enFile)) {
|
||||
errors.push(`fragments/${name} : paire fr/en incomplète`);
|
||||
continue;
|
||||
}
|
||||
const fr = loadJson(path.join(fragmentsDir, frFile));
|
||||
const en = loadJson(path.join(fragmentsDir, enFile));
|
||||
if (fr && en) structuralDiff(`fragments/${frFile}`, fr, `fragments/${enFile}`, en);
|
||||
}
|
||||
|
||||
// --- Avertissements : clés consolidées sans fragment (dette, non bloquant) ----
|
||||
for (const lang of ['fr']) { // fr suffit : la parité fr/en est déjà vérifiée
|
||||
const uncovered = [...consolidatedLeaves[lang].keys()].filter(p => !fragmentLeafOwner[lang].has(p));
|
||||
if (uncovered.length > 0) {
|
||||
warnings.push(`${uncovered.length} clé(s) de ${lang}.json sans fragment (dette de maintenance), ex. : ${uncovered.slice(0, 5).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rapport ------------------------------------------------------------------
|
||||
for (const w of warnings) console.warn(`i18n (avertissement) : ${w}`);
|
||||
if (errors.length > 0) {
|
||||
console.error(`\ni18n : ${errors.length} incohérence(s) bloquante(s) :\n`);
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`i18n OK — ${Object.keys(consolidated.fr ?? {}).length} sections, ${consolidatedLeaves.fr.size} clés, ${fragmentFiles.length} fragments.`);
|
||||
@@ -4,7 +4,6 @@ import { hiddenInDemoGuard } from './guards/demo-mode.guard';
|
||||
export const routes: Routes = [
|
||||
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
|
||||
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
|
||||
{ path: 'lore/:loreId/graph', loadComponent: () => import('./lore/lore-graph/lore-graph.component').then(m => m.LoreGraphComponent) },
|
||||
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
|
||||
@@ -17,6 +16,7 @@ export const routes: Routes = [
|
||||
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
|
||||
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
|
||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||
{ path: 'campaigns/:campaignId/graph', loadComponent: () => import('./campaigns/campaign-graph/campaign-graph.component').then(m => m.CampaignGraphComponent) },
|
||||
{ path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) },
|
||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) },
|
||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -109,7 +110,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -127,7 +129,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
|
||||
// réutilise le composant quand on navigue entre arcs frères via l'arbre
|
||||
// (même route pattern), et ngOnInit ne se relance pas.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
@if (campaign) {
|
||||
<div class="graph-page">
|
||||
<header class="graph-header">
|
||||
<button type="button" class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
{{ 'campaignGraph.back' | translate }}
|
||||
</button>
|
||||
<div class="graph-title">
|
||||
<h1>
|
||||
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
|
||||
{{ 'campaignGraph.title' | translate:{ name: campaign.name } }}
|
||||
</h1>
|
||||
<p class="graph-subtitle">
|
||||
{{ 'campaignGraph.subtitle' | translate:{ pages: pageCount, npcs: npcCount, scenes: sceneCount, quests: questCount, edges: edgeCount } }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Légende cliquable : masquer/afficher un type d'entité (préférence mémorisée). -->
|
||||
<div class="graph-legend">
|
||||
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> {{ 'campaignGraph.legendPage' | translate }}</span>
|
||||
<button type="button" class="legend-item legend-toggle" [class.legend-toggle--off]="isHidden('npc')"
|
||||
(click)="toggleKind('npc')" [title]="'campaignGraph.toggleHint' | translate">
|
||||
<span class="legend-dot legend-dot--npc"></span> {{ 'campaignGraph.legendNpc' | translate }}
|
||||
</button>
|
||||
<button type="button" class="legend-item legend-toggle" [class.legend-toggle--off]="isHidden('scene')"
|
||||
(click)="toggleKind('scene')" [title]="'campaignGraph.toggleHint' | translate">
|
||||
<span class="legend-dot legend-dot--scene"></span> {{ 'campaignGraph.legendScene' | translate }}
|
||||
</button>
|
||||
<button type="button" class="legend-item legend-toggle" [class.legend-toggle--off]="isHidden('quest')"
|
||||
(click)="toggleKind('quest')" [title]="'campaignGraph.toggleHint' | translate">
|
||||
<span class="legend-dot legend-dot--quest"></span> {{ 'campaignGraph.legendQuest' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn-back" (click)="resetLayout()"
|
||||
[title]="'campaignGraph.resetTitle' | translate">
|
||||
<lucide-icon [img]="RotateCcw" [size]="14"></lucide-icon>
|
||||
{{ 'campaignGraph.reset' | translate }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (!hasLore) {
|
||||
<div class="graph-empty">
|
||||
{{ 'campaignGraph.noLore' | translate }}
|
||||
</div>
|
||||
} @else if (nodes.length === 0) {
|
||||
<div class="graph-empty">
|
||||
{{ 'campaignGraph.empty' | translate }}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="graph-scroll">
|
||||
<svg #svgEl
|
||||
[attr.width]="svgWidth"
|
||||
[attr.height]="svgHeight"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerUp($event)"
|
||||
(pointerleave)="onPointerUp($event)">
|
||||
|
||||
<!-- Arêtes (sous les nœuds) — estompées quand un autre nœud a le focus -->
|
||||
@for (e of edges; track e.key) {
|
||||
<line
|
||||
[attr.x1]="e.x1" [attr.y1]="e.y1"
|
||||
[attr.x2]="e.x2" [attr.y2]="e.y2"
|
||||
[class.edge-page]="e.kind === 'page'"
|
||||
[class.edge-npc]="e.kind === 'npc'"
|
||||
[class.edge-scene]="e.kind === 'scene'"
|
||||
[class.edge-quest]="e.kind === 'quest'"
|
||||
[class.dimmed]="edgeDimmed(e)" />
|
||||
}
|
||||
|
||||
<!-- Nœuds : survol = focus sur le nœud et ses voisins -->
|
||||
@for (n of nodes; track n.id) {
|
||||
<g class="node"
|
||||
[class.node--npc]="n.kind === 'npc'"
|
||||
[class.node--scene]="n.kind === 'scene'"
|
||||
[class.node--quest]="n.kind === 'quest'"
|
||||
[class.dragging]="draggingId === n.id"
|
||||
[class.dimmed]="nodeDimmed(n)"
|
||||
(pointerenter)="onNodeEnter(n)"
|
||||
(pointerleave)="onNodeLeave()"
|
||||
(pointerdown)="onPointerDown($event, n)">
|
||||
<circle [attr.cx]="n.x" [attr.cy]="n.y" [attr.r]="radiusOf(n)" />
|
||||
<text class="node-label"
|
||||
[attr.x]="n.x"
|
||||
[attr.y]="n.y + radiusOf(n) + 14"
|
||||
text-anchor="middle">
|
||||
@for (line of n.lines; track $index) {
|
||||
<tspan [attr.x]="n.x" [attr.dy]="$index === 0 ? 0 : 12">{{ line }}</tspan>
|
||||
}
|
||||
</text>
|
||||
<title>{{ n.label }}</title>
|
||||
</g>
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
@if (edgeCount === 0) {
|
||||
<p class="graph-hint">
|
||||
{{ 'campaignGraph.hint' | translate }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -57,6 +57,24 @@
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
// Entrées cliquables : masquer/afficher un type d'entité.
|
||||
.legend-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover { color: #e5e7eb; }
|
||||
|
||||
&.legend-toggle--off {
|
||||
opacity: 0.4;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
@@ -65,6 +83,8 @@
|
||||
|
||||
&.legend-dot--page { background: #4338ca; border: 1px solid #818cf8; }
|
||||
&.legend-dot--npc { background: #92400e; border: 1px solid #fbbf24; }
|
||||
&.legend-dot--scene { background: #365314; border: 1px solid #a3e635; }
|
||||
&.legend-dot--quest { background: #581c87; border: 1px solid #d8b4fe; }
|
||||
}
|
||||
|
||||
.legend-line {
|
||||
@@ -91,6 +111,13 @@ svg {
|
||||
display: block;
|
||||
touch-action: none; // requis pour le drag au pointeur sur tactile
|
||||
|
||||
// Focus au survol : tout ce qui n'est pas le nœud survolé ou un voisin s'estompe.
|
||||
line, .node {
|
||||
transition: opacity 0.12s;
|
||||
|
||||
&.dimmed { opacity: 0.12; }
|
||||
}
|
||||
|
||||
line {
|
||||
&.edge-page {
|
||||
stroke: #4f4f7a;
|
||||
@@ -103,6 +130,20 @@ svg {
|
||||
stroke-dasharray: 5 4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.edge-scene {
|
||||
stroke: #65a30d;
|
||||
stroke-width: 1.4;
|
||||
stroke-dasharray: 5 4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.edge-quest {
|
||||
stroke: #a855f7;
|
||||
stroke-width: 1.4;
|
||||
stroke-dasharray: 5 4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.node {
|
||||
@@ -143,6 +184,36 @@ svg {
|
||||
stroke: #fbbf24;
|
||||
}
|
||||
}
|
||||
|
||||
// Scènes : palette verte (cohérente avec les cartes de scène de l'app).
|
||||
&.node--scene {
|
||||
circle {
|
||||
fill: #1a2e05;
|
||||
stroke: #65a30d;
|
||||
}
|
||||
|
||||
.node-label { fill: #d9f99d; }
|
||||
|
||||
&:hover circle {
|
||||
fill: #365314;
|
||||
stroke: #a3e635;
|
||||
}
|
||||
}
|
||||
|
||||
// Quêtes : palette pourpre claire, distincte de l'indigo des pages.
|
||||
&.node--quest {
|
||||
circle {
|
||||
fill: #3b0764;
|
||||
stroke: #a855f7;
|
||||
}
|
||||
|
||||
.node-label { fill: #e9d5ff; }
|
||||
|
||||
&:hover circle {
|
||||
fill: #581c87;
|
||||
stroke: #c084fc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
688
web/src/app/campaigns/campaign-graph/campaign-graph.component.ts
Normal file
688
web/src/app/campaigns/campaign-graph/campaign-graph.component.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, ArrowLeft, Network, RotateCcw } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Scene } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../campaign-tree.helper';
|
||||
|
||||
/** Type d'un nœud du graphe (préfixe des ids, style et légende). */
|
||||
type NodeKind = 'page' | 'npc' | 'scene' | 'quest';
|
||||
|
||||
/** Nœud du graphe : une page de Lore, ou une entité de campagne qui référence des pages. */
|
||||
interface GraphNode {
|
||||
id: string; // '<kind>:<id>' (évite les collisions d'IDs entre tables)
|
||||
kind: NodeKind;
|
||||
label: string;
|
||||
/** Libellé affiché sur 1 à 2 lignes (moins de troncature qu'une ligne unique). */
|
||||
lines: string[];
|
||||
route: string[]; // navigation au clic
|
||||
x: number; // centre du nœud (coords SVG)
|
||||
y: number;
|
||||
degree: number; // nombre de liens (taille du nœud)
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
key: string;
|
||||
kind: NodeKind; // page↔page ou entité→page (style distinct par type)
|
||||
a: string; // ids des nœuds reliés — pour le focus au survol
|
||||
b: string;
|
||||
x1: number; y1: number; x2: number; y2: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphe des liens d'une CAMPAGNE : ses PNJ, scènes et quêtes reliés aux pages
|
||||
* de son univers (Lore).
|
||||
*
|
||||
* Nœuds = toutes les pages du Lore associé + les entités de la campagne (PNJ,
|
||||
* scènes, quêtes) qui référencent au moins une page. Arêtes = `relatedPageIds`
|
||||
* des pages (liens page↔page) et des entités (liens entité→page).
|
||||
*
|
||||
* Vivait historiquement côté Lore ; déplacé côté campagne car il mélange des
|
||||
* entités de campagne (PNJ) avec les pages — c'est une vue de PRÉPARATION de
|
||||
* campagne, pas une vue d'univers.
|
||||
*
|
||||
* Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au
|
||||
* chargement, puis nœuds déplaçables à la souris — même approche SVG custom
|
||||
* que chapter-graph, sans dépendance externe.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-campaign-graph',
|
||||
imports: [RouterModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './campaign-graph.component.html',
|
||||
styleUrls: ['./campaign-graph.component.scss']
|
||||
})
|
||||
export class CampaignGraphComponent implements OnInit, OnDestroy {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Network = Network;
|
||||
readonly RotateCcw = RotateCcw;
|
||||
|
||||
campaignId = '';
|
||||
campaign: Campaign | null = null;
|
||||
/** Faux tant que la campagne n'a pas d'univers (Lore) associé → état vide dédié. */
|
||||
hasLore = false;
|
||||
|
||||
nodes: GraphNode[] = [];
|
||||
edges: GraphEdge[] = [];
|
||||
npcCount = 0;
|
||||
sceneCount = 0;
|
||||
questCount = 0;
|
||||
pageCount = 0;
|
||||
edgeCount = 0;
|
||||
|
||||
readonly MAX_LABEL_CHARS = 22;
|
||||
private readonly MARGIN = 70;
|
||||
|
||||
svgWidth = 800;
|
||||
svgHeight = 600;
|
||||
|
||||
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
|
||||
|
||||
draggingId: string | null = null;
|
||||
private dragOffsetX = 0;
|
||||
private dragOffsetY = 0;
|
||||
private dragMoved = false;
|
||||
private readonly DRAG_THRESHOLD = 4;
|
||||
|
||||
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
|
||||
private adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
|
||||
|
||||
// Disposition personnalisée : positions sauvegardées sur la campagne, sauvegarde
|
||||
// différée après un drag (évite un PUT par pixel déplacé).
|
||||
private savedPositions: Record<string, { x: number; y: number }> | null = null;
|
||||
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private positionsDirty = false;
|
||||
|
||||
// Filtres de la légende : types d'entités masqués (persistés en localStorage).
|
||||
private static readonly HIDDEN_KINDS_KEY = 'loremind.campaignGraph.hiddenKinds';
|
||||
hiddenKinds = new Set<NodeKind>();
|
||||
// Données brutes gardées pour reconstruire le graphe quand on (dé)masque un type.
|
||||
private cachedPages: Page[] = [];
|
||||
private cachedTree: CampaignTreeData | null = null;
|
||||
|
||||
// Focus au survol : le nœud survolé + ses voisins restent nets, le reste s'estompe.
|
||||
hoveredId: string | null = null;
|
||||
private hoverNeighbors = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
|
||||
try {
|
||||
const raw = localStorage.getItem(CampaignGraphComponent.HIDDEN_KINDS_KEY);
|
||||
if (raw) this.hiddenKinds = new Set(JSON.parse(raw) as NodeKind[]);
|
||||
} catch { /* préférence corrompue → tout visible */ }
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
}).pipe(
|
||||
switchMap(({ campaign, allCampaigns, treeData }) => {
|
||||
this.campaign = campaign;
|
||||
this.hasLore = !!campaign.loreId;
|
||||
this.savedPositions = this.parsePositions(campaign.graphPositions);
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate));
|
||||
this.pageTitleService.set(this.translate.instant('campaignGraph.title', { name: campaign.name }));
|
||||
// Les PNJ arrivent déjà avec l'arbre (une seule requête /tree) ; seules
|
||||
// les pages du Lore associé restent à charger.
|
||||
const pages$ = campaign.loreId ? this.pageService.getByLoreId(campaign.loreId) : of([] as Page[]);
|
||||
return pages$.pipe(map(pages => ({ pages, treeData })));
|
||||
})
|
||||
).subscribe(({ pages, treeData }) => {
|
||||
this.cachedPages = pages;
|
||||
this.cachedTree = treeData;
|
||||
this.buildGraph(pages, treeData);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Filtres de la légende --------------------------------------------------
|
||||
|
||||
isHidden(kind: NodeKind): boolean {
|
||||
return this.hiddenKinds.has(kind);
|
||||
}
|
||||
|
||||
/** (Dé)masque un type d'entité et reconstruit le graphe (positions sauvées conservées). */
|
||||
toggleKind(kind: NodeKind): void {
|
||||
if (this.hiddenKinds.has(kind)) this.hiddenKinds.delete(kind);
|
||||
else this.hiddenKinds.add(kind);
|
||||
try {
|
||||
localStorage.setItem(CampaignGraphComponent.HIDDEN_KINDS_KEY, JSON.stringify([...this.hiddenKinds]));
|
||||
} catch { /* stockage indisponible : préférence non persistée */ }
|
||||
this.hoveredId = null;
|
||||
this.hoverNeighbors.clear();
|
||||
if (this.cachedTree) this.buildGraph(this.cachedPages, this.cachedTree);
|
||||
}
|
||||
|
||||
// --- Focus au survol ----------------------------------------------------------
|
||||
|
||||
onNodeEnter(node: GraphNode): void {
|
||||
if (this.draggingId) return; // pas de changement de focus en plein drag
|
||||
this.hoveredId = node.id;
|
||||
this.hoverNeighbors = new Set(
|
||||
this.adjacency.flatMap(e => e.a === node.id ? [e.b] : e.b === node.id ? [e.a] : []));
|
||||
}
|
||||
|
||||
onNodeLeave(): void {
|
||||
this.hoveredId = null;
|
||||
this.hoverNeighbors.clear();
|
||||
}
|
||||
|
||||
nodeDimmed(node: GraphNode): boolean {
|
||||
return this.hoveredId !== null && node.id !== this.hoveredId && !this.hoverNeighbors.has(node.id);
|
||||
}
|
||||
|
||||
edgeDimmed(edge: GraphEdge): boolean {
|
||||
return this.hoveredId !== null && edge.a !== this.hoveredId && edge.b !== this.hoveredId;
|
||||
}
|
||||
|
||||
// --- Construction du graphe ----------------------------------------------
|
||||
|
||||
private buildGraph(pages: Page[], treeData: CampaignTreeData): void {
|
||||
const loreId = this.campaign?.loreId ?? '';
|
||||
const pageIds = new Set(pages.map(p => p.id!));
|
||||
this.pageCount = pages.length;
|
||||
|
||||
// Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines).
|
||||
const nodes: GraphNode[] = pages.map(p => ({
|
||||
id: `page:${p.id}`,
|
||||
kind: 'page' as const,
|
||||
label: p.title,
|
||||
lines: this.splitLabel(p.title),
|
||||
route: ['/lore', loreId, 'pages', p.id!],
|
||||
x: 0, y: 0, degree: 0
|
||||
}));
|
||||
|
||||
const linksTo = (ids?: string[]) => (ids ?? []).some(pid => pageIds.has(pid));
|
||||
|
||||
// Entités de campagne : seulement celles qui référencent au moins une page du
|
||||
// Lore (une entité sans lien n'apporte rien à la carte), et dont le TYPE n'est
|
||||
// pas masqué par la légende.
|
||||
const linkedNpcs = this.hiddenKinds.has('npc') ? [] : treeData.npcs.filter(n => linksTo(n.relatedPageIds));
|
||||
for (const n of linkedNpcs) {
|
||||
nodes.push({
|
||||
id: `npc:${n.id}`,
|
||||
kind: 'npc',
|
||||
label: n.name,
|
||||
lines: this.splitLabel(n.name),
|
||||
route: ['/campaigns', this.campaignId, 'npcs', n.id!],
|
||||
x: 0, y: 0, degree: 0
|
||||
});
|
||||
}
|
||||
this.npcCount = linkedNpcs.length;
|
||||
|
||||
// Scènes liées : la route de détail exige arc + chapitre → index inverse depuis l'arbre.
|
||||
const arcOfChapter = new Map<string, string>();
|
||||
for (const [arcId, chapters] of Object.entries(treeData.chaptersByArc)) {
|
||||
for (const ch of chapters) arcOfChapter.set(ch.id!, arcId);
|
||||
}
|
||||
const linkedScenes: Array<{ scene: Scene; chapterId: string }> = [];
|
||||
if (!this.hiddenKinds.has('scene')) {
|
||||
for (const [chapterId, scenes] of Object.entries(treeData.scenesByChapter)) {
|
||||
if (!arcOfChapter.has(chapterId)) continue;
|
||||
for (const sc of scenes) {
|
||||
if (linksTo(sc.relatedPageIds)) linkedScenes.push({ scene: sc, chapterId });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { scene, chapterId } of linkedScenes) {
|
||||
nodes.push({
|
||||
id: `scene:${scene.id}`,
|
||||
kind: 'scene',
|
||||
label: scene.name,
|
||||
lines: this.splitLabel(scene.name),
|
||||
route: ['/campaigns', this.campaignId, 'arcs', arcOfChapter.get(chapterId)!,
|
||||
'chapters', chapterId, 'scenes', scene.id!],
|
||||
x: 0, y: 0, degree: 0
|
||||
});
|
||||
}
|
||||
this.sceneCount = linkedScenes.length;
|
||||
|
||||
const linkedQuests = this.hiddenKinds.has('quest')
|
||||
? [] : (treeData.quests ?? []).filter(q => linksTo(q.relatedPageIds));
|
||||
for (const q of linkedQuests) {
|
||||
nodes.push({
|
||||
id: `quest:${q.id}`,
|
||||
kind: 'quest',
|
||||
label: q.name,
|
||||
lines: this.splitLabel(q.name),
|
||||
route: ['/campaigns', this.campaignId, 'quests', q.id!],
|
||||
x: 0, y: 0, degree: 0
|
||||
});
|
||||
}
|
||||
this.questCount = linkedQuests.length;
|
||||
|
||||
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
|
||||
// (A→B et B→A = un seul trait) ; les liens entité→page portent le type de l'entité.
|
||||
const adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
|
||||
const seenPairs = new Set<string>();
|
||||
for (const p of pages) {
|
||||
for (const targetId of p.relatedPageIds ?? []) {
|
||||
if (!pageIds.has(targetId) || targetId === p.id) continue;
|
||||
const pair = [p.id!, targetId].sort().join('|');
|
||||
if (seenPairs.has(pair)) continue;
|
||||
seenPairs.add(pair);
|
||||
adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` });
|
||||
}
|
||||
}
|
||||
const entityEdges = (kind: NodeKind, id: string, relatedPageIds?: string[]) => {
|
||||
for (const targetId of new Set(relatedPageIds ?? [])) {
|
||||
if (!pageIds.has(targetId)) continue;
|
||||
adjacency.push({ key: `${kind}:${id}|${targetId}`, kind, a: `${kind}:${id}`, b: `page:${targetId}` });
|
||||
}
|
||||
};
|
||||
for (const n of linkedNpcs) entityEdges('npc', n.id!, n.relatedPageIds);
|
||||
for (const { scene } of linkedScenes) entityEdges('scene', scene.id!, scene.relatedPageIds);
|
||||
for (const q of linkedQuests) entityEdges('quest', q.id!, q.relatedPageIds);
|
||||
this.adjacency = adjacency;
|
||||
this.edgeCount = adjacency.length;
|
||||
|
||||
// Degré (pondère la taille des nœuds : une capitale très liée ressort).
|
||||
const degree = new Map<string, number>();
|
||||
for (const e of adjacency) {
|
||||
degree.set(e.a, (degree.get(e.a) ?? 0) + 1);
|
||||
degree.set(e.b, (degree.get(e.b) ?? 0) + 1);
|
||||
}
|
||||
for (const node of nodes) {
|
||||
node.degree = degree.get(node.id) ?? 0;
|
||||
}
|
||||
|
||||
this.nodes = nodes;
|
||||
this.runForceLayout();
|
||||
this.applySavedPositions();
|
||||
this.recomputeEdges();
|
||||
}
|
||||
|
||||
// --- Disposition personnalisée (persistée sur la campagne) -----------------
|
||||
|
||||
private parsePositions(json: string | null | undefined): Record<string, { x: number; y: number }> | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null; // JSON corrompu → disposition auto, sans casser l'écran
|
||||
}
|
||||
}
|
||||
|
||||
/** Réapplique les positions sauvegardées ; les nœuds inconnus gardent le layout auto. */
|
||||
private applySavedPositions(): void {
|
||||
if (!this.savedPositions) return;
|
||||
let applied = false;
|
||||
for (const node of this.nodes) {
|
||||
const p = this.savedPositions[node.id];
|
||||
if (!p || typeof p.x !== 'number' || typeof p.y !== 'number') continue;
|
||||
node.x = p.x;
|
||||
node.y = p.y;
|
||||
applied = true;
|
||||
}
|
||||
if (applied) this.fitSvgToNodes();
|
||||
}
|
||||
|
||||
/** Sauvegarde différée (800 ms après le dernier drag) de la disposition courante. */
|
||||
private scheduleSave(): void {
|
||||
this.positionsDirty = true;
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(() => this.savePositions(), 800);
|
||||
}
|
||||
|
||||
private savePositions(): void {
|
||||
if (!this.positionsDirty || !this.campaign?.id) return;
|
||||
this.positionsDirty = false;
|
||||
const positions: Record<string, { x: number; y: number }> = {};
|
||||
for (const n of this.nodes) positions[n.id] = { x: Math.round(n.x), y: Math.round(n.y) };
|
||||
this.savedPositions = positions;
|
||||
this.campaignService.updateGraphPositions(this.campaign.id, JSON.stringify(positions)).subscribe();
|
||||
}
|
||||
|
||||
/** Recalcule la disposition automatique et oublie les déplacements manuels. */
|
||||
resetLayout(): void {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.positionsDirty = false;
|
||||
this.savedPositions = null;
|
||||
this.runForceLayout();
|
||||
this.recomputeEdges();
|
||||
if (this.campaign?.id) {
|
||||
this.campaignService.updateGraphPositions(this.campaign.id, '').subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout force-directed (Fruchterman-Reingold simplifié) :
|
||||
* répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le
|
||||
* centre (regroupe les composantes déconnectées), refroidissement progressif.
|
||||
* Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf.
|
||||
* convention projet d'éviter Math.random pour des rendus reproductibles).
|
||||
*/
|
||||
private runForceLayout(): void {
|
||||
const n = this.nodes.length;
|
||||
if (n === 0) {
|
||||
this.svgWidth = 800; this.svgHeight = 400;
|
||||
return;
|
||||
}
|
||||
const side = Math.max(600, Math.ceil(170 * Math.sqrt(n)));
|
||||
const w = side, h = side;
|
||||
const cx = w / 2, cy = h / 2;
|
||||
|
||||
// Init en spirale : angle d'or → répartition uniforme et déterministe.
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
this.nodes.forEach((node, i) => {
|
||||
const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN);
|
||||
const a = i * golden;
|
||||
node.x = cx + r * Math.cos(a);
|
||||
node.y = cy + r * Math.sin(a);
|
||||
});
|
||||
|
||||
const index = new Map(this.nodes.map(node => [node.id, node]));
|
||||
const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds
|
||||
let temperature = side / 8;
|
||||
|
||||
for (let iter = 0; iter < 300; iter++) {
|
||||
const dx = new Map<string, number>();
|
||||
const dy = new Map<string, number>();
|
||||
for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); }
|
||||
|
||||
// Répulsion entre toutes les paires.
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const a = this.nodes[i], b = this.nodes[j];
|
||||
let vx = a.x - b.x, vy = a.y - b.y;
|
||||
let d = Math.hypot(vx, vy);
|
||||
if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); }
|
||||
const force = (k * k) / d;
|
||||
dx.set(a.id, dx.get(a.id)! + (vx / d) * force);
|
||||
dy.set(a.id, dy.get(a.id)! + (vy / d) * force);
|
||||
dx.set(b.id, dx.get(b.id)! - (vx / d) * force);
|
||||
dy.set(b.id, dy.get(b.id)! - (vy / d) * force);
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction le long des arêtes.
|
||||
for (const e of this.adjacency) {
|
||||
const a = index.get(e.a)!, b = index.get(e.b)!;
|
||||
const vx = a.x - b.x, vy = a.y - b.y;
|
||||
const d = Math.max(0.01, Math.hypot(vx, vy));
|
||||
const force = (d * d) / k;
|
||||
dx.set(a.id, dx.get(a.id)! - (vx / d) * force);
|
||||
dy.set(a.id, dy.get(a.id)! - (vy / d) * force);
|
||||
dx.set(b.id, dx.get(b.id)! + (vx / d) * force);
|
||||
dy.set(b.id, dy.get(b.id)! + (vy / d) * force);
|
||||
}
|
||||
|
||||
// Gravité douce vers le centre (sinon les composantes isolées fuient).
|
||||
for (const node of this.nodes) {
|
||||
dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06);
|
||||
dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06);
|
||||
}
|
||||
|
||||
// Application bornée par la température, SANS clamp au cadre : le clamp à
|
||||
// chaque itération écrasait des rangées de nœuds contre les bords (libellés
|
||||
// illisibles). Le cadrage se fait après coup dans normalizeToFrame().
|
||||
for (const node of this.nodes) {
|
||||
const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!;
|
||||
const d = Math.max(0.01, Math.hypot(ddx, ddy));
|
||||
const step = Math.min(d, temperature);
|
||||
node.x += (ddx / d) * step;
|
||||
node.y += (ddy / d) * step;
|
||||
}
|
||||
temperature *= 0.96;
|
||||
}
|
||||
|
||||
// Sans clamp, le nuage libre est très étendu (la gravité n'équilibre la
|
||||
// répulsion qu'à grande distance) : on le remet d'abord à l'échelle du cadre
|
||||
// cible, puis l'anti-chevauchement ré-étend LOCALEMENT là où c'est nécessaire.
|
||||
this.rescaleTo(side);
|
||||
this.fanOutLeaves();
|
||||
this.resolveLabelOverlaps();
|
||||
this.normalizeToFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose en ÉVENTAIL les feuilles (degré 1) autour de leur hub (degré ≥ 3) :
|
||||
* huit PNJ pointant le même lieu forment une couronne régulière au lieu d'un
|
||||
* amas. Angles déterministes (départ = direction hub→extérieur du nuage).
|
||||
*/
|
||||
private fanOutLeaves(): void {
|
||||
const neighbors = new Map<string, string[]>();
|
||||
for (const e of this.adjacency) {
|
||||
(neighbors.get(e.a) ?? neighbors.set(e.a, []).get(e.a)!).push(e.b);
|
||||
(neighbors.get(e.b) ?? neighbors.set(e.b, []).get(e.b)!).push(e.a);
|
||||
}
|
||||
const leavesByHub = new Map<string, GraphNode[]>();
|
||||
for (const node of this.nodes) {
|
||||
const nbs = neighbors.get(node.id) ?? [];
|
||||
if (nbs.length !== 1) continue;
|
||||
const hubId = nbs[0];
|
||||
if ((neighbors.get(hubId)?.length ?? 0) < 3) continue;
|
||||
(leavesByHub.get(hubId) ?? leavesByHub.set(hubId, []).get(hubId)!).push(node);
|
||||
}
|
||||
if (leavesByHub.size === 0) return;
|
||||
|
||||
const index = new Map(this.nodes.map(n => [n.id, n]));
|
||||
const cx = this.nodes.reduce((s, n) => s + n.x, 0) / this.nodes.length;
|
||||
const cy = this.nodes.reduce((s, n) => s + n.y, 0) / this.nodes.length;
|
||||
for (const [hubId, leaves] of leavesByHub) {
|
||||
const hub = index.get(hubId);
|
||||
if (!hub) continue;
|
||||
leaves.sort((a, b) => a.label.localeCompare(b.label)); // ordre stable
|
||||
const radius = 85 + leaves.length * 6;
|
||||
// Première feuille vers l'extérieur du nuage : la couronne empiète moins
|
||||
// sur le cœur du graphe.
|
||||
const start = Math.atan2(hub.y - cy, hub.x - cx);
|
||||
const step = (2 * Math.PI) / leaves.length;
|
||||
leaves.forEach((leaf, i) => {
|
||||
leaf.x = hub.x + radius * Math.cos(start + i * step);
|
||||
leaf.y = hub.y + radius * Math.sin(start + i * step);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Réduit homothétiquement le nuage pour tenir dans un carré `side` (jamais d'agrandissement). */
|
||||
private rescaleTo(side: number): void {
|
||||
const xs = this.nodes.map(n => n.x), ys = this.nodes.map(n => n.y);
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
const span = Math.max(1, maxX - minX, maxY - minY);
|
||||
const scale = Math.min(1, (side - 2 * this.MARGIN) / span);
|
||||
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
|
||||
for (const node of this.nodes) {
|
||||
node.x = cx + (node.x - cx) * scale;
|
||||
node.y = cy + (node.y - cy) * scale;
|
||||
}
|
||||
}
|
||||
|
||||
/** Largeur approximative du libellé affiché (police 11px ≈ 6.4px/caractère, ligne la plus longue). */
|
||||
private labelWidth(node: GraphNode): number {
|
||||
const longest = node.lines.reduce((max, l) => Math.max(max, l.length), 0);
|
||||
return longest * 6.4 + 14;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anti-chevauchement TENANT COMPTE DES LIBELLÉS : le layout de force espace les
|
||||
* centres des cercles, mais un libellé fait ~120px de large — deux nœuds voisins
|
||||
* se lisaient l'un sur l'autre. Écarte itérativement chaque paire trop proche
|
||||
* selon l'axe le moins coûteux. Déterministe (pas d'aléatoire).
|
||||
*/
|
||||
private resolveLabelOverlaps(): void {
|
||||
const nodes = this.nodes;
|
||||
for (let iter = 0; iter < 80; iter++) {
|
||||
let moved = false;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i], b = nodes[j];
|
||||
const needX = (this.labelWidth(a) + this.labelWidth(b)) / 2 + 6;
|
||||
const needY = this.radiusOf(a) + this.radiusOf(b) + 42; // cercles + jusqu'à 2 lignes de texte
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
if (Math.abs(dx) >= needX || Math.abs(dy) >= needY) continue;
|
||||
const overlapX = needX - Math.abs(dx);
|
||||
const overlapY = needY - Math.abs(dy);
|
||||
if (overlapX / needX < overlapY / needY) {
|
||||
const shift = overlapX / 2 + 0.5;
|
||||
const sign = dx !== 0 ? Math.sign(dx) : (i % 2 === 0 ? 1 : -1);
|
||||
a.x -= sign * shift;
|
||||
b.x += sign * shift;
|
||||
} else {
|
||||
const shift = overlapY / 2 + 0.5;
|
||||
const sign = dy !== 0 ? Math.sign(dy) : (i % 2 === 0 ? 1 : -1);
|
||||
a.y -= sign * shift;
|
||||
b.y += sign * shift;
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recadre le nuage dans le SVG : translation pour que tout (cercles ET libellés)
|
||||
* soit visible avec une marge, taille du SVG = étendue réelle du graphe.
|
||||
*/
|
||||
private normalizeToFrame(): void {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const node of this.nodes) {
|
||||
const half = Math.max(this.labelWidth(node) / 2, this.radiusOf(node));
|
||||
minX = Math.min(minX, node.x - half);
|
||||
maxX = Math.max(maxX, node.x + half);
|
||||
minY = Math.min(minY, node.y - this.radiusOf(node));
|
||||
maxY = Math.max(maxY, node.y + this.radiusOf(node) + 38); // libellé (2 lignes max) sous le cercle
|
||||
}
|
||||
const pad = 24;
|
||||
for (const node of this.nodes) {
|
||||
node.x += pad - minX;
|
||||
node.y += pad - minY;
|
||||
}
|
||||
this.svgWidth = Math.max(600, Math.ceil(maxX - minX + pad * 2));
|
||||
this.svgHeight = Math.max(400, Math.ceil(maxY - minY + pad * 2));
|
||||
}
|
||||
|
||||
/** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */
|
||||
private recomputeEdges(): void {
|
||||
const index = new Map(this.nodes.map(n => [n.id, n]));
|
||||
this.edges = this.adjacency
|
||||
.filter(e => index.has(e.a) && index.has(e.b))
|
||||
.map(e => {
|
||||
const a = index.get(e.a)!, b = index.get(e.b)!;
|
||||
return { key: e.key, kind: e.kind, a: e.a, b: e.b, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
|
||||
});
|
||||
}
|
||||
|
||||
/** Rayon d'un nœud : grossit doucement avec son nombre de liens. */
|
||||
radiusOf(node: GraphNode): number {
|
||||
return 14 + Math.min(10, node.degree * 1.5);
|
||||
}
|
||||
|
||||
// --- Interactions (drag pour réarranger, clic pour ouvrir) ----------------
|
||||
|
||||
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
|
||||
const svg = this.svgEl?.nativeElement;
|
||||
if (!svg) return { x: evt.clientX, y: evt.clientY };
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = evt.clientX;
|
||||
pt.y = evt.clientY;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return { x: evt.clientX, y: evt.clientY };
|
||||
const local = pt.matrixTransform(ctm.inverse());
|
||||
return { x: local.x, y: local.y };
|
||||
}
|
||||
|
||||
onPointerDown(evt: PointerEvent, node: GraphNode): void {
|
||||
if (evt.button !== 0) return;
|
||||
evt.preventDefault();
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
this.draggingId = node.id;
|
||||
this.dragOffsetX = x - node.x;
|
||||
this.dragOffsetY = y - node.y;
|
||||
this.dragMoved = false;
|
||||
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||
}
|
||||
|
||||
onPointerMove(evt: PointerEvent): void {
|
||||
if (!this.draggingId) return;
|
||||
const node = this.nodes.find(n => n.id === this.draggingId);
|
||||
if (!node) return;
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX);
|
||||
const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY);
|
||||
if (!this.dragMoved) {
|
||||
if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return;
|
||||
this.dragMoved = true;
|
||||
}
|
||||
node.x = newX;
|
||||
node.y = newY;
|
||||
this.recomputeEdges();
|
||||
this.fitSvgToNodes();
|
||||
}
|
||||
|
||||
onPointerUp(evt: PointerEvent): void {
|
||||
if (!this.draggingId) return;
|
||||
const id = this.draggingId;
|
||||
const moved = this.dragMoved;
|
||||
this.draggingId = null;
|
||||
this.dragMoved = false;
|
||||
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
|
||||
if (moved) {
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
// Clic simple → ouvre la fiche de l'entité (page, PNJ, scène, quête).
|
||||
const node = this.nodes.find(n => n.id === id);
|
||||
if (node) this.router.navigate(node.route);
|
||||
}
|
||||
|
||||
/** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */
|
||||
private fitSvgToNodes(): void {
|
||||
for (const n of this.nodes) {
|
||||
if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN;
|
||||
if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe un libellé en 1 à 2 lignes de {@link MAX_LABEL_CHARS} max (coupure aux
|
||||
* mots) : « 1 - Le convoi de marchands » reste lisible au lieu d'être tronqué.
|
||||
*/
|
||||
private splitLabel(text: string): string[] {
|
||||
const max = this.MAX_LABEL_CHARS;
|
||||
if (text.length <= max) return [text];
|
||||
let line1 = '';
|
||||
for (const word of text.split(/\s+/)) {
|
||||
const candidate = line1 ? `${line1} ${word}` : word;
|
||||
if (candidate.length > max) break;
|
||||
line1 = candidate;
|
||||
}
|
||||
if (!line1) line1 = text.slice(0, max); // premier mot plus long que la ligne
|
||||
let rest = text.slice(line1.length).trim();
|
||||
if (!rest) return [line1];
|
||||
if (rest.length > max) rest = rest.slice(0, max - 1) + '…';
|
||||
return [line1, rest];
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Une sauvegarde en attente part immédiatement (sinon le drag serait perdu).
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.savePositions();
|
||||
}
|
||||
// Sidebar : volontairement rien — le composant suivant appellera show().
|
||||
// Eviter d'appeler hide() ici previent le clignotement.
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { Component, EventEmitter, OnInit, Output, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -53,7 +54,8 @@ export class CampaignCreateComponent implements OnInit {
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private loreService: LoreService,
|
||||
private gameSystemService: GameSystemService
|
||||
private gameSystemService: GameSystemService,
|
||||
private destroyRef: DestroyRef
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -77,7 +79,7 @@ export class CampaignCreateComponent implements OnInit {
|
||||
// Detecte la selection de l'option sentinelle "Creer un systeme" et bascule
|
||||
// en mode creation inline. On reinitialise immediatement le control a ''
|
||||
// pour que la sentinelle ne reste pas en valeur reelle du form.
|
||||
this.form.get('gameSystemId')?.valueChanges.subscribe(value => {
|
||||
this.form.get('gameSystemId')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
|
||||
this.form.get('gameSystemId')?.setValue('', { emitEvent: false });
|
||||
this.startCreateGameSystem();
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||
[title]="'campaignDetail.graphTitle' | translate">
|
||||
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
|
||||
{{ 'campaignDetail.graph' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="exportPdf()" [disabled]="exportingPdf">
|
||||
<lucide-icon [img]="FileText" [size]="14"></lucide-icon>
|
||||
{{ (exportingPdf ? 'campaignDetail.pdfExporting' : 'campaignDetail.pdfExport') | translate }}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X } from 'lucide-angular';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X, Network } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of, Observable } from 'rxjs';
|
||||
@@ -57,6 +57,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly X = X;
|
||||
readonly Network = Network;
|
||||
|
||||
/** Export Foundry en cours (anti double-clic). */
|
||||
exportingFoundry = false;
|
||||
@@ -416,6 +417,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ouvre le graphe des liens : PNJ de la campagne reliés aux pages de son Lore. */
|
||||
openGraph(): void {
|
||||
if (this.campaign?.id) {
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'graph']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Génère et télécharge le livret PDF de la campagne. */
|
||||
exportPdf(): void {
|
||||
if (!this.campaign?.id || this.exportingPdf) return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -114,7 +115,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -131,7 +133,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
const newChapterId = pm.get('chapterId')!;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
@@ -111,11 +112,12 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
private enemyService: EnemyService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
this.campaignId = pm.get('campaignId')!;
|
||||
this.arcId = pm.get('arcId')!;
|
||||
this.chapterId = pm.get('chapterId')!;
|
||||
@@ -130,7 +132,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
scenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||
}).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||
this.chapter = chapter;
|
||||
this.scenes = scenes;
|
||||
// Mode plat = campagne simple (1 arc, 1 chapitre) SANS compter la plomberie
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -54,11 +55,12 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
const newChapterId = pm.get('chapterId')!;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -66,11 +67,12 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const cid = pm.get('campaignId')!;
|
||||
const pid = pm.get('playthroughId')!;
|
||||
if (cid !== this.campaignId || pid !== this.playthroughId) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
@@ -42,11 +43,12 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
private playthroughService: PlaythroughService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const cid = pm.get('campaignId')!;
|
||||
const pid = pm.get('playthroughId')!;
|
||||
if (cid !== this.campaignId || pid !== this.playthroughId) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -106,7 +107,8 @@ export class QuestEditComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -118,7 +120,7 @@ export class QuestEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
this.campaignId = pm.get('campaignId')!;
|
||||
this.questId = pm.get('questId'); // null sur la route /create
|
||||
this.arcIdParam = this.route.snapshot.queryParamMap.get('arcId'); // ?arcId= depuis un arc HUB
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
@@ -66,11 +67,12 @@ export class QuestViewComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newQuestId = pm.get('questId')!;
|
||||
if (newQuestId !== this.questId || newCampaignId !== this.campaignId) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular';
|
||||
@@ -35,11 +36,12 @@ export class RandomTableViewComponent implements OnInit {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: RandomTableService,
|
||||
private campaignSidebar: CampaignSidebarService
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId');
|
||||
const newTableId = pm.get('tableId');
|
||||
if (newCampaignId === this.campaignId && newTableId === this.tableId) return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -169,7 +170,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -195,7 +197,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
|
||||
// réutilise le composant quand on navigue entre scènes frères via l'arbre
|
||||
// (même route pattern), et ngOnInit ne se relance pas.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
const newChapterId = pm.get('chapterId')!;
|
||||
@@ -226,7 +228,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
|
||||
})
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(({ campaign, allCampaigns, scene, chapterScenes, treeData, pages, loreId }) => {
|
||||
this.scene = scene;
|
||||
this.pageTitleService.set(scene.name);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -57,11 +58,12 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
const newChapterId = pm.get('chapterId')!;
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
<p class="description">{{ lore.description }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||
[title]="'loreDetail.graphTitle' | translate">
|
||||
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
|
||||
{{ 'loreDetail.graph' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="startEdit()" [title]="'loreDetail.editTitle' | translate">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
{{ 'common.edit' | translate }}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, Folder, Plus, Pencil, Trash2, Network } from 'lucide-angular';
|
||||
import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
@@ -25,7 +26,6 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Plus = Plus;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Network = Network;
|
||||
|
||||
lore: Lore | null = null;
|
||||
/** Tous les dossiers du Lore (racines + enfants). */
|
||||
@@ -47,14 +47,15 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// On s'abonne à paramMap (pas snapshot) pour recharger quand on switche
|
||||
// d'un Lore à l'autre via la liste globale de la sidebar — Angular réutilise
|
||||
// le même composant et ngOnInit ne se relance pas tout seul.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const id = pm.get('id');
|
||||
if (id && id !== this.lore?.id) {
|
||||
this.load(id);
|
||||
@@ -85,13 +86,6 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
|
||||
}
|
||||
|
||||
/** Ouvre la vue graphe : pages du Lore + PNJ liés, reliés par leurs liens. */
|
||||
openGraph(): void {
|
||||
if (this.lore?.id) {
|
||||
this.router.navigate(['/lore', this.lore.id, 'graph']);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression du Lore ───────────────
|
||||
|
||||
startEdit(): void {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
@if (lore) {
|
||||
<div class="graph-page">
|
||||
<header class="graph-header">
|
||||
<button type="button" class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
{{ 'loreGraph.back' | translate }}
|
||||
</button>
|
||||
<div class="graph-title">
|
||||
<h1>
|
||||
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
|
||||
{{ 'loreGraph.title' | translate:{ name: lore.name } }}
|
||||
</h1>
|
||||
<p class="graph-subtitle">
|
||||
{{ 'loreGraph.subtitle' | translate:{ pages: (nodes.length - npcCount), npcs: npcCount, edges: edgeCount } }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="graph-legend">
|
||||
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> {{ 'loreGraph.legendPage' | translate }}</span>
|
||||
<span class="legend-item"><span class="legend-dot legend-dot--npc"></span> {{ 'loreGraph.legendNpc' | translate }}</span>
|
||||
<span class="legend-item"><span class="legend-line legend-line--npc"></span> {{ 'loreGraph.legendLink' | translate }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (nodes.length === 0) {
|
||||
<div class="graph-empty">
|
||||
{{ 'loreGraph.empty' | translate }}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="graph-scroll">
|
||||
<svg #svgEl
|
||||
[attr.width]="svgWidth"
|
||||
[attr.height]="svgHeight"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerUp($event)"
|
||||
(pointerleave)="onPointerUp($event)">
|
||||
|
||||
<!-- Arêtes (sous les nœuds) -->
|
||||
@for (e of edges; track e.key) {
|
||||
<line
|
||||
[attr.x1]="e.x1" [attr.y1]="e.y1"
|
||||
[attr.x2]="e.x2" [attr.y2]="e.y2"
|
||||
[class.edge-page]="e.kind === 'page'"
|
||||
[class.edge-npc]="e.kind === 'npc'" />
|
||||
}
|
||||
|
||||
<!-- Nœuds -->
|
||||
@for (n of nodes; track n.id) {
|
||||
<g class="node"
|
||||
[class.node--npc]="n.kind === 'npc'"
|
||||
[class.dragging]="draggingId === n.id"
|
||||
(pointerdown)="onPointerDown($event, n)">
|
||||
<circle [attr.cx]="n.x" [attr.cy]="n.y" [attr.r]="radiusOf(n)" />
|
||||
<text class="node-label"
|
||||
[attr.x]="n.x"
|
||||
[attr.y]="n.y + radiusOf(n) + 14"
|
||||
text-anchor="middle">{{ n.displayLabel }}</text>
|
||||
<title>{{ n.label }}</title>
|
||||
</g>
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
@if (edgeCount === 0) {
|
||||
<p class="graph-hint">
|
||||
{{ 'loreGraph.hint' | translate }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft, Network } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { NpcService } from '../../services/npc.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { Npc } from '../../services/npc.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
/** Nœud du graphe : une page de Lore ou un PNJ qui référence des pages. */
|
||||
interface GraphNode {
|
||||
id: string; // 'page:<id>' ou 'npc:<id>' (évite les collisions d'IDs)
|
||||
kind: 'page' | 'npc';
|
||||
label: string;
|
||||
displayLabel: string;
|
||||
route: string[]; // navigation au clic
|
||||
x: number; // centre du nœud (coords SVG)
|
||||
y: number;
|
||||
degree: number; // nombre de liens (taille du nœud)
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
key: string;
|
||||
kind: 'page' | 'npc'; // page↔page ou npc→page (style distinct)
|
||||
x1: number; y1: number; x2: number; y2: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphe du Lore : vue d'ensemble des pages et de leurs liens.
|
||||
*
|
||||
* Nœuds = toutes les pages du Lore + les PNJ (toutes campagnes liées au Lore)
|
||||
* qui référencent au moins une page. Arêtes = `relatedPageIds` des pages
|
||||
* (liens page↔page) et des PNJ (liens PNJ→page).
|
||||
*
|
||||
* Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au
|
||||
* chargement, puis nœuds déplaçables à la souris — même approche SVG custom
|
||||
* que chapter-graph, sans dépendance externe.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lore-graph',
|
||||
imports: [RouterModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './lore-graph.component.html',
|
||||
styleUrls: ['./lore-graph.component.scss']
|
||||
})
|
||||
export class LoreGraphComponent implements OnInit, OnDestroy {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Network = Network;
|
||||
|
||||
loreId = '';
|
||||
lore: Lore | null = null;
|
||||
|
||||
nodes: GraphNode[] = [];
|
||||
edges: GraphEdge[] = [];
|
||||
npcCount = 0;
|
||||
edgeCount = 0;
|
||||
|
||||
readonly MAX_LABEL_CHARS = 22;
|
||||
private readonly MARGIN = 70;
|
||||
|
||||
svgWidth = 800;
|
||||
svgHeight = 600;
|
||||
|
||||
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
|
||||
|
||||
draggingId: string | null = null;
|
||||
private dragOffsetX = 0;
|
||||
private dragOffsetY = 0;
|
||||
private dragMoved = false;
|
||||
private readonly DRAG_THRESHOLD = 4;
|
||||
|
||||
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
|
||||
private adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
npcs: this.npcService.getByLore(this.loreId)
|
||||
}).subscribe(({ sidebar, npcs }) => {
|
||||
this.lore = sidebar.lore;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.pageTitleService.set(this.translate.instant('loreGraph.title', { name: sidebar.lore.name }));
|
||||
this.buildGraph(sidebar.pages, npcs);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Construction du graphe ----------------------------------------------
|
||||
|
||||
private buildGraph(pages: Page[], npcs: Npc[]): void {
|
||||
const pageIds = new Set(pages.map(p => p.id!));
|
||||
|
||||
// Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines).
|
||||
const nodes: GraphNode[] = pages.map(p => ({
|
||||
id: `page:${p.id}`,
|
||||
kind: 'page' as const,
|
||||
label: p.title,
|
||||
displayLabel: this.truncate(p.title),
|
||||
route: ['/lore', this.loreId, 'pages', p.id!],
|
||||
x: 0, y: 0, degree: 0
|
||||
}));
|
||||
|
||||
// Nœuds PNJ : seulement ceux qui référencent au moins une page de CE lore
|
||||
// (un PNJ sans lien n'apporte rien à la carte des connexions).
|
||||
const linkedNpcs = npcs.filter(n =>
|
||||
(n.relatedPageIds ?? []).some(pid => pageIds.has(pid)));
|
||||
for (const n of linkedNpcs) {
|
||||
nodes.push({
|
||||
id: `npc:${n.id}`,
|
||||
kind: 'npc',
|
||||
label: n.name,
|
||||
displayLabel: this.truncate(n.name),
|
||||
route: ['/campaigns', n.campaignId, 'npcs', n.id!],
|
||||
x: 0, y: 0, degree: 0
|
||||
});
|
||||
}
|
||||
this.npcCount = linkedNpcs.length;
|
||||
|
||||
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
|
||||
// (A→B et B→A = un seul trait).
|
||||
const adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
|
||||
const seenPairs = new Set<string>();
|
||||
for (const p of pages) {
|
||||
for (const targetId of p.relatedPageIds ?? []) {
|
||||
if (!pageIds.has(targetId) || targetId === p.id) continue;
|
||||
const pair = [p.id!, targetId].sort().join('|');
|
||||
if (seenPairs.has(pair)) continue;
|
||||
seenPairs.add(pair);
|
||||
adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` });
|
||||
}
|
||||
}
|
||||
for (const n of linkedNpcs) {
|
||||
for (const targetId of new Set(n.relatedPageIds ?? [])) {
|
||||
if (!pageIds.has(targetId)) continue;
|
||||
adjacency.push({ key: `np:${n.id}|${targetId}`, kind: 'npc', a: `npc:${n.id}`, b: `page:${targetId}` });
|
||||
}
|
||||
}
|
||||
this.adjacency = adjacency;
|
||||
this.edgeCount = adjacency.length;
|
||||
|
||||
// Degré (pondère la taille des nœuds : une capitale très liée ressort).
|
||||
const degree = new Map<string, number>();
|
||||
for (const e of adjacency) {
|
||||
degree.set(e.a, (degree.get(e.a) ?? 0) + 1);
|
||||
degree.set(e.b, (degree.get(e.b) ?? 0) + 1);
|
||||
}
|
||||
for (const node of nodes) {
|
||||
node.degree = degree.get(node.id) ?? 0;
|
||||
}
|
||||
|
||||
this.nodes = nodes;
|
||||
this.runForceLayout();
|
||||
this.recomputeEdges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout force-directed (Fruchterman-Reingold simplifié) :
|
||||
* répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le
|
||||
* centre (regroupe les composantes déconnectées), refroidissement progressif.
|
||||
* Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf.
|
||||
* convention projet d'éviter Math.random pour des rendus reproductibles).
|
||||
*/
|
||||
private runForceLayout(): void {
|
||||
const n = this.nodes.length;
|
||||
if (n === 0) {
|
||||
this.svgWidth = 800; this.svgHeight = 400;
|
||||
return;
|
||||
}
|
||||
const side = Math.max(600, Math.ceil(170 * Math.sqrt(n)));
|
||||
const w = side, h = side;
|
||||
const cx = w / 2, cy = h / 2;
|
||||
|
||||
// Init en spirale : angle d'or → répartition uniforme et déterministe.
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
this.nodes.forEach((node, i) => {
|
||||
const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN);
|
||||
const a = i * golden;
|
||||
node.x = cx + r * Math.cos(a);
|
||||
node.y = cy + r * Math.sin(a);
|
||||
});
|
||||
|
||||
const index = new Map(this.nodes.map(node => [node.id, node]));
|
||||
const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds
|
||||
let temperature = side / 8;
|
||||
|
||||
for (let iter = 0; iter < 300; iter++) {
|
||||
const dx = new Map<string, number>();
|
||||
const dy = new Map<string, number>();
|
||||
for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); }
|
||||
|
||||
// Répulsion entre toutes les paires.
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const a = this.nodes[i], b = this.nodes[j];
|
||||
let vx = a.x - b.x, vy = a.y - b.y;
|
||||
let d = Math.hypot(vx, vy);
|
||||
if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); }
|
||||
const force = (k * k) / d;
|
||||
dx.set(a.id, dx.get(a.id)! + (vx / d) * force);
|
||||
dy.set(a.id, dy.get(a.id)! + (vy / d) * force);
|
||||
dx.set(b.id, dx.get(b.id)! - (vx / d) * force);
|
||||
dy.set(b.id, dy.get(b.id)! - (vy / d) * force);
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction le long des arêtes.
|
||||
for (const e of this.adjacency) {
|
||||
const a = index.get(e.a)!, b = index.get(e.b)!;
|
||||
const vx = a.x - b.x, vy = a.y - b.y;
|
||||
const d = Math.max(0.01, Math.hypot(vx, vy));
|
||||
const force = (d * d) / k;
|
||||
dx.set(a.id, dx.get(a.id)! - (vx / d) * force);
|
||||
dy.set(a.id, dy.get(a.id)! - (vy / d) * force);
|
||||
dx.set(b.id, dx.get(b.id)! + (vx / d) * force);
|
||||
dy.set(b.id, dy.get(b.id)! + (vy / d) * force);
|
||||
}
|
||||
|
||||
// Gravité douce vers le centre (sinon les composantes isolées fuient).
|
||||
for (const node of this.nodes) {
|
||||
dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06);
|
||||
dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06);
|
||||
}
|
||||
|
||||
// Application bornée par la température, dans le cadre.
|
||||
for (const node of this.nodes) {
|
||||
const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!;
|
||||
const d = Math.max(0.01, Math.hypot(ddx, ddy));
|
||||
const step = Math.min(d, temperature);
|
||||
node.x = Math.min(w - this.MARGIN, Math.max(this.MARGIN, node.x + (ddx / d) * step));
|
||||
node.y = Math.min(h - this.MARGIN, Math.max(this.MARGIN, node.y + (ddy / d) * step));
|
||||
}
|
||||
temperature *= 0.96;
|
||||
}
|
||||
|
||||
this.svgWidth = w;
|
||||
this.svgHeight = h;
|
||||
}
|
||||
|
||||
/** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */
|
||||
private recomputeEdges(): void {
|
||||
const index = new Map(this.nodes.map(n => [n.id, n]));
|
||||
this.edges = this.adjacency
|
||||
.filter(e => index.has(e.a) && index.has(e.b))
|
||||
.map(e => {
|
||||
const a = index.get(e.a)!, b = index.get(e.b)!;
|
||||
return { key: e.key, kind: e.kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
|
||||
});
|
||||
}
|
||||
|
||||
/** Rayon d'un nœud : grossit doucement avec son nombre de liens. */
|
||||
radiusOf(node: GraphNode): number {
|
||||
return 14 + Math.min(10, node.degree * 1.5);
|
||||
}
|
||||
|
||||
// --- Interactions (drag pour réarranger, clic pour ouvrir) ----------------
|
||||
|
||||
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
|
||||
const svg = this.svgEl?.nativeElement;
|
||||
if (!svg) return { x: evt.clientX, y: evt.clientY };
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = evt.clientX;
|
||||
pt.y = evt.clientY;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return { x: evt.clientX, y: evt.clientY };
|
||||
const local = pt.matrixTransform(ctm.inverse());
|
||||
return { x: local.x, y: local.y };
|
||||
}
|
||||
|
||||
onPointerDown(evt: PointerEvent, node: GraphNode): void {
|
||||
if (evt.button !== 0) return;
|
||||
evt.preventDefault();
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
this.draggingId = node.id;
|
||||
this.dragOffsetX = x - node.x;
|
||||
this.dragOffsetY = y - node.y;
|
||||
this.dragMoved = false;
|
||||
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||
}
|
||||
|
||||
onPointerMove(evt: PointerEvent): void {
|
||||
if (!this.draggingId) return;
|
||||
const node = this.nodes.find(n => n.id === this.draggingId);
|
||||
if (!node) return;
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX);
|
||||
const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY);
|
||||
if (!this.dragMoved) {
|
||||
if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return;
|
||||
this.dragMoved = true;
|
||||
}
|
||||
node.x = newX;
|
||||
node.y = newY;
|
||||
this.recomputeEdges();
|
||||
this.fitSvgToNodes();
|
||||
}
|
||||
|
||||
onPointerUp(evt: PointerEvent): void {
|
||||
if (!this.draggingId) return;
|
||||
const id = this.draggingId;
|
||||
const moved = this.dragMoved;
|
||||
this.draggingId = null;
|
||||
this.dragMoved = false;
|
||||
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
|
||||
if (moved) return;
|
||||
// Clic simple → ouvre la page / la fiche PNJ.
|
||||
const node = this.nodes.find(n => n.id === id);
|
||||
if (node) this.router.navigate(node.route);
|
||||
}
|
||||
|
||||
/** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */
|
||||
private fitSvgToNodes(): void {
|
||||
for (const n of this.nodes) {
|
||||
if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN;
|
||||
if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN;
|
||||
}
|
||||
}
|
||||
|
||||
private truncate(text: string): string {
|
||||
return text.length > this.MAX_LABEL_CHARS
|
||||
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
|
||||
: text;
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement.
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -62,7 +63,8 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private destroyRef: DestroyRef
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -76,7 +78,7 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Réagir aux changements de :folderId (navigation entre dossiers dans la sidebar
|
||||
// sans démonter le composant).
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newId = pm.get('folderId')!;
|
||||
if (newId !== this.folderId) {
|
||||
this.folderId = newId;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
@@ -67,7 +68,8 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
title: ['', Validators.required],
|
||||
@@ -116,7 +118,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
this.form.get('nodeId')?.valueChanges.subscribe(nodeId => {
|
||||
this.form.get('nodeId')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(nodeId => {
|
||||
this.autoSelectTemplateForNode(nodeId);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
@@ -109,7 +110,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {
|
||||
this.chatPrimaryAction = { label: this.translate.instant('pageEdit.chatPrimaryAction') };
|
||||
this.chatQuickSuggestions = [
|
||||
@@ -126,7 +128,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
// navigue d'une page à une autre (ex. via les chips du lore-link-picker),
|
||||
// Angular réutilise le composant et ngOnInit ne se relance pas → l'écran
|
||||
// resterait figé sur l'ancienne page.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newPageId = pm.get('pageId')!;
|
||||
if (newPageId && newPageId !== this.pageId) {
|
||||
this.pageId = newPageId;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
@@ -65,14 +66,15 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
// Même pattern que page-edit : on s'abonne à paramMap pour gérer la
|
||||
// navigation d'une page à l'autre (Angular réutilise le composant).
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newPageId = pm.get('pageId')!;
|
||||
if (newPageId && newPageId !== this.pageId) {
|
||||
this.pageId = newPageId;
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface Campaign {
|
||||
loreId?: string | null;
|
||||
/** ID du GameSystem associé (weak reference cross-context). `null` = campagne générique. */
|
||||
gameSystemId?: string | null;
|
||||
/** Positions des nœuds du graphe de campagne (JSON `"<kind>:<id>" -> {x,y}`). Null = auto. */
|
||||
graphPositions?: string | null;
|
||||
}
|
||||
|
||||
// Interface pour la création de Campaign (sans id)
|
||||
|
||||
@@ -73,6 +73,11 @@ export class CampaignService {
|
||||
return this.http.get<Campaign>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Sauvegarde la disposition du graphe de campagne (JSON opaque ; '' = disposition auto). */
|
||||
updateGraphPositions(id: string, positionsJson: string): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/${id}/graph-positions`, { positions: positionsJson });
|
||||
}
|
||||
|
||||
/** Bilan de préparation (Pilier B) — alimente les pastilles de l'arbre de la sidebar. */
|
||||
getReadiness(id: string): Observable<CampaignReadinessAssessment> {
|
||||
return this.http.get<CampaignReadinessAssessment>(`${this.apiUrl}/${id}/readiness`);
|
||||
|
||||
@@ -16,11 +16,6 @@ export class NpcService {
|
||||
return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||
}
|
||||
|
||||
/** PNJ de toutes les campagnes liées à un Lore — alimente le graphe du Lore. */
|
||||
getByLore(loreId: string): Observable<Npc[]> {
|
||||
return this.http.get<Npc[]>(`${this.apiUrl}/lore/${loreId}`);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Npc> {
|
||||
return this.http.get<Npc>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
@@ -198,8 +198,6 @@
|
||||
"submit": "Create lore"
|
||||
},
|
||||
"loreDetail": {
|
||||
"graph": "Graph",
|
||||
"graphTitle": "View the graph of pages and their links (NPCs included)",
|
||||
"editTitle": "Edit Lore",
|
||||
"deleteTitle": "Delete Lore",
|
||||
"folders": "Folders",
|
||||
@@ -221,14 +219,19 @@
|
||||
"irreversible": "This action is irreversible."
|
||||
}
|
||||
},
|
||||
"loreGraph": {
|
||||
"back": "Back to Lore",
|
||||
"campaignGraph": {
|
||||
"back": "Back to campaign",
|
||||
"title": "{{name}} — Graph",
|
||||
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
|
||||
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{scenes}} scene(s) · {{quests}} quest(s) · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
|
||||
"legendPage": "Lore page",
|
||||
"legendNpc": "NPC",
|
||||
"legendLink": "NPC → page link",
|
||||
"empty": "No pages in this Lore yet — the graph will fill in as you go.",
|
||||
"legendScene": "Scene",
|
||||
"legendQuest": "Quest",
|
||||
"reset": "Auto layout",
|
||||
"resetTitle": "Recompute the automatic layout (forgets your manual moves)",
|
||||
"toggleHint": "Click to hide / show this entity type",
|
||||
"noLore": "No world (Lore) is linked to this campaign: link one from the campaign header to see the graph.",
|
||||
"empty": "No pages in the linked world yet — the graph will fill in as you go.",
|
||||
"hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet."
|
||||
},
|
||||
"loreNodeCreate": {
|
||||
@@ -613,6 +616,8 @@
|
||||
"foundryExporting": "Exporting…",
|
||||
"pdfExport": "Export to PDF",
|
||||
"pdfExporting": "Generating…",
|
||||
"graph": "Graph",
|
||||
"graphTitle": "View the graph of NPCs and their linked lore pages",
|
||||
"gameSystemChange": {
|
||||
"title": "Change the game system?",
|
||||
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",
|
||||
|
||||
@@ -198,8 +198,6 @@
|
||||
"submit": "Créer le lore"
|
||||
},
|
||||
"loreDetail": {
|
||||
"graph": "Graphe",
|
||||
"graphTitle": "Visualiser le graphe des pages et de leurs liens (PNJ inclus)",
|
||||
"editTitle": "Modifier le Lore",
|
||||
"deleteTitle": "Supprimer le Lore",
|
||||
"folders": "Dossiers",
|
||||
@@ -221,14 +219,19 @@
|
||||
"irreversible": "Cette action est irréversible."
|
||||
}
|
||||
},
|
||||
"loreGraph": {
|
||||
"back": "Retour au Lore",
|
||||
"campaignGraph": {
|
||||
"back": "Retour à la campagne",
|
||||
"title": "{{name}} — Graphe",
|
||||
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
|
||||
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{scenes}} scène(s) · {{quests}} quête(s) · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
|
||||
"legendPage": "Page de Lore",
|
||||
"legendNpc": "PNJ",
|
||||
"legendLink": "Lien PNJ → page",
|
||||
"empty": "Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.",
|
||||
"legendScene": "Scène",
|
||||
"legendQuest": "Quête",
|
||||
"reset": "Disposition auto",
|
||||
"resetTitle": "Recalculer la disposition automatique (oublie vos déplacements manuels)",
|
||||
"toggleHint": "Cliquer pour masquer / afficher ce type d'entité",
|
||||
"noLore": "Aucun univers (Lore) n'est associé à cette campagne : associez-en un depuis l'en-tête de la campagne pour voir le graphe.",
|
||||
"empty": "Aucune page dans l'univers associé pour l'instant — le graphe se remplira au fur et à mesure.",
|
||||
"hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche."
|
||||
},
|
||||
"loreNodeCreate": {
|
||||
@@ -613,6 +616,8 @@
|
||||
"foundryExporting": "Export…",
|
||||
"pdfExport": "Exporter en PDF",
|
||||
"pdfExporting": "Génération…",
|
||||
"graph": "Graphe",
|
||||
"graphTitle": "Visualiser le graphe des PNJ et des pages de lore liées",
|
||||
"gameSystemChange": {
|
||||
"title": "Changer le système de jeu ?",
|
||||
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
"foundryExporting": "Exporting…",
|
||||
"pdfExport": "Export to PDF",
|
||||
"pdfExporting": "Generating…",
|
||||
"graph": "Graph",
|
||||
"graphTitle": "View the graph of NPCs and their linked lore pages",
|
||||
"gameSystemChange": {
|
||||
"title": "Change the game system?",
|
||||
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",
|
||||
@@ -104,6 +106,21 @@
|
||||
"irreversible": "This action is irreversible."
|
||||
}
|
||||
},
|
||||
"campaignGraph": {
|
||||
"back": "Back to campaign",
|
||||
"title": "{{name}} — Graph",
|
||||
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{scenes}} scene(s) · {{quests}} quest(s) · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
|
||||
"legendPage": "Lore page",
|
||||
"legendNpc": "NPC",
|
||||
"legendScene": "Scene",
|
||||
"legendQuest": "Quest",
|
||||
"reset": "Auto layout",
|
||||
"resetTitle": "Recompute the automatic layout (forgets your manual moves)",
|
||||
"toggleHint": "Click to hide / show this entity type",
|
||||
"noLore": "No world (Lore) is linked to this campaign: link one from the campaign header to see the graph.",
|
||||
"empty": "No pages in the linked world yet — the graph will fill in as you go.",
|
||||
"hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet."
|
||||
},
|
||||
"campaignImport": {
|
||||
"pageTitle": "Import a campaign",
|
||||
"back": "Back to the campaign",
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
"foundryExporting": "Export…",
|
||||
"pdfExport": "Exporter en PDF",
|
||||
"pdfExporting": "Génération…",
|
||||
"graph": "Graphe",
|
||||
"graphTitle": "Visualiser le graphe des PNJ et des pages de lore liées",
|
||||
"gameSystemChange": {
|
||||
"title": "Changer le système de jeu ?",
|
||||
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",
|
||||
@@ -104,6 +106,21 @@
|
||||
"irreversible": "Cette action est irréversible."
|
||||
}
|
||||
},
|
||||
"campaignGraph": {
|
||||
"back": "Retour à la campagne",
|
||||
"title": "{{name}} — Graphe",
|
||||
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{scenes}} scène(s) · {{quests}} quête(s) · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
|
||||
"legendPage": "Page de Lore",
|
||||
"legendNpc": "PNJ",
|
||||
"legendScene": "Scène",
|
||||
"legendQuest": "Quête",
|
||||
"reset": "Disposition auto",
|
||||
"resetTitle": "Recalculer la disposition automatique (oublie vos déplacements manuels)",
|
||||
"toggleHint": "Cliquer pour masquer / afficher ce type d'entité",
|
||||
"noLore": "Aucun univers (Lore) n'est associé à cette campagne : associez-en un depuis l'en-tête de la campagne pour voir le graphe.",
|
||||
"empty": "Aucune page dans l'univers associé pour l'instant — le graphe se remplira au fur et à mesure.",
|
||||
"hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche."
|
||||
},
|
||||
"campaignImport": {
|
||||
"pageTitle": "Importer une campagne",
|
||||
"back": "Retour à la campagne",
|
||||
|
||||
@@ -45,8 +45,6 @@
|
||||
"submit": "Create lore"
|
||||
},
|
||||
"loreDetail": {
|
||||
"graph": "Graph",
|
||||
"graphTitle": "View the graph of pages and their links (NPCs included)",
|
||||
"editTitle": "Edit Lore",
|
||||
"deleteTitle": "Delete Lore",
|
||||
"folders": "Folders",
|
||||
@@ -68,16 +66,6 @@
|
||||
"irreversible": "This action is irreversible."
|
||||
}
|
||||
},
|
||||
"loreGraph": {
|
||||
"back": "Back to Lore",
|
||||
"title": "{{name}} — Graph",
|
||||
"subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{edges}} link(s). Click a node to open it, drag it to rearrange.",
|
||||
"legendPage": "Lore page",
|
||||
"legendNpc": "NPC",
|
||||
"legendLink": "NPC → page link",
|
||||
"empty": "No pages in this Lore yet — the graph will fill in as you go.",
|
||||
"hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet."
|
||||
},
|
||||
"loreNodeCreate": {
|
||||
"title": "Create a new folder",
|
||||
"subtitle": "Folders let you organize your pages by category",
|
||||
|
||||
@@ -45,8 +45,6 @@
|
||||
"submit": "Créer le lore"
|
||||
},
|
||||
"loreDetail": {
|
||||
"graph": "Graphe",
|
||||
"graphTitle": "Visualiser le graphe des pages et de leurs liens (PNJ inclus)",
|
||||
"editTitle": "Modifier le Lore",
|
||||
"deleteTitle": "Supprimer le Lore",
|
||||
"folders": "Dossiers",
|
||||
@@ -68,16 +66,6 @@
|
||||
"irreversible": "Cette action est irréversible."
|
||||
}
|
||||
},
|
||||
"loreGraph": {
|
||||
"back": "Retour au Lore",
|
||||
"title": "{{name}} — Graphe",
|
||||
"subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.",
|
||||
"legendPage": "Page de Lore",
|
||||
"legendNpc": "PNJ",
|
||||
"legendLink": "Lien PNJ → page",
|
||||
"empty": "Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.",
|
||||
"hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche."
|
||||
},
|
||||
"loreNodeCreate": {
|
||||
"title": "Créer un nouveau dossier",
|
||||
"subtitle": "Les dossiers permettent d'organiser vos pages par catégorie",
|
||||
|
||||
Reference in New Issue
Block a user