Ajout de drag'n drop là ou c'est possible ; ajout de l'import des ennemis depuis foundryVTT
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m33s
Build & Push Images / build-switcher (push) Successful in 17s

This commit is contained in:
2026-06-25 15:21:25 +02:00
parent 18f5a260b0
commit 9bd66613b6
34 changed files with 439 additions and 21 deletions

View File

@@ -81,6 +81,55 @@ public class EnemyService {
enemyRepository.deleteById(id);
}
// --- Import de monstres depuis un compendium Foundry -------------------
/** Un monstre du catalogue Foundry : nom + référence (UUID de compendium). */
public record MonsterImport(String name, String foundryRef) {}
public record MonsterImportResult(int created, int updated) {}
/**
* Importe (upsert) des monstres Foundry dans le bestiaire d'une campagne.
* Dédup par {@code foundryRef} : un monstre déjà importé est mis à jour (nom),
* jamais dupliqué. Fiche minimale (nom + référence) ; les stats restent côté
* Foundry et sont ré-instanciées à l'export.
*/
public MonsterImportResult importFoundryMonsters(String campaignId, List<MonsterImport> monsters) {
List<Enemy> existing = enemyRepository.findByCampaignId(campaignId);
Map<String, Enemy> byRef = new HashMap<>();
for (Enemy e : existing) {
if (e.getFoundryRef() != null) byRef.put(e.getFoundryRef(), e);
}
int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1;
int created = 0, updated = 0;
for (MonsterImport m : monsters) {
if (m.foundryRef() == null || m.foundryRef().isBlank()
|| m.name() == null || m.name().isBlank()) {
continue;
}
Enemy ex = byRef.get(m.foundryRef());
if (ex != null) {
ex.setName(m.name());
enemyRepository.save(ex);
updated++;
} else {
Enemy saved = enemyRepository.save(Enemy.builder()
.name(m.name())
.foundryRef(m.foundryRef())
.campaignId(campaignId)
.order(order++)
.values(new HashMap<>())
.imageValues(new HashMap<>())
.keyValueValues(new HashMap<>())
.build());
byRef.put(m.foundryRef(), saved);
created++;
}
}
return new MonsterImportResult(created, updated);
}
public List<Enemy> searchEnemies(String query) {
if (query == null || query.isBlank()) return List.of();
return enemyRepository.searchByName(query.trim());

View File

@@ -47,6 +47,14 @@ public class Enemy {
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/**
* Référence vers l'acteur Foundry d'origine (UUID de compendium, ex.
* {@code Compendium.nimble.monsters.Actor.abc123}). Renseigné quand l'ennemi
* a été importé depuis un compendium Foundry ; permet, à l'export, de poser
* un token du VRAI acteur (stats natives). Null pour un ennemi fait main.
*/
private String foundryRef;
/** Ordre d'affichage dans la liste. */
private int order;

View File

@@ -63,6 +63,10 @@ public class EnemyJpaEntity {
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
/** UUID de l'acteur de compendium Foundry d'origine (import bestiaire). Nullable. */
@Column(name = "foundry_ref", length = 512)
private String foundryRef;
@Column(name = "\"order\"", nullable = false)
private int order;

View File

@@ -61,6 +61,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.foundryRef(e.getFoundryRef())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
@@ -79,6 +80,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.foundryRef(n.getFoundryRef())
.order(n.getOrder())
.createdAt(n.getCreatedAt())
.updatedAt(n.getUpdatedAt())

View File

@@ -364,7 +364,7 @@ public class ExportService {
private ContentExport.EnemyDto toEnemyDto(EnemyJpaEntity e) {
return new ContentExport.EnemyDto(e.getId(), e.getName(), e.getLevel(), e.getFolder(),
e.getPortraitImageId(), e.getHeaderImageId(), e.getValues(), e.getImageValues(),
e.getKeyValueValues(), e.getCampaignId(), e.getOrder());
e.getKeyValueValues(), e.getCampaignId(), e.getFoundryRef(), e.getOrder());
}
private ContentExport.ItemCatalogDto toItemCatalogDto(ItemCatalogJpaEntity e) {

View File

@@ -335,6 +335,7 @@ public class ImportService {
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.setOrder(d.order());
enemyMap.put(d.id(), enemyRepo.save(e).getId());
}

View File

@@ -209,6 +209,7 @@ public record ContentExport(
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
Long campaignId,
String foundryRef,
int order
) {}

View File

@@ -35,6 +35,7 @@ public final class FoundryBundle {
List<Scene> scenes,
List<Persona> npcs,
List<Persona> enemies,
List<RandomTable> randomTables,
List<Asset> assets
) {}
@@ -75,7 +76,8 @@ public final class FoundryBundle {
public record Persona(
String id, String name, String folder, int order,
String portraitAssetId, String headerAssetId, String level, List<Field> fields
String portraitAssetId, String headerAssetId, String level,
String foundryRef, List<Field> fields
) {}
/** Champ de fiche resolu : {type, label} + selon le type value | entries | assetIds. */
@@ -83,5 +85,10 @@ public final class FoundryBundle {
public record Entry(String label, String value) {}
/** Table aléatoire -> RollTable Foundry (formule + entrées avec intervalles). */
public record RandomTable(String id, String name, String description, String diceFormula, List<RandomTableEntry> entries) {}
public record RandomTableEntry(int minRoll, int maxRoll, String label, String detail) {}
public record Asset(String id, String kind, String path, String filename, String mime, long sizeBytes) {}
}

View File

@@ -43,6 +43,7 @@ public class FoundryExportService {
private final SceneJpaRepository sceneRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final RandomTableJpaRepository randomTableRepo;
private final GameSystemJpaRepository gameSystemRepo;
private final ImageJpaRepository imageRepo;
private final StoredFileJpaRepository storedFileRepo;
@@ -57,6 +58,7 @@ public class FoundryExportService {
SceneJpaRepository sceneRepo,
NpcJpaRepository npcRepo,
EnemyJpaRepository enemyRepo,
RandomTableJpaRepository randomTableRepo,
GameSystemJpaRepository gameSystemRepo,
ImageJpaRepository imageRepo,
StoredFileJpaRepository storedFileRepo,
@@ -70,6 +72,7 @@ public class FoundryExportService {
this.sceneRepo = sceneRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.randomTableRepo = randomTableRepo;
this.gameSystemRepo = gameSystemRepo;
this.imageRepo = imageRepo;
this.storedFileRepo = storedFileRepo;
@@ -130,7 +133,7 @@ public class FoundryExportService {
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
npcs.add(new FoundryBundle.Persona(
str(n.getId()), n.getName(), n.getFolder(), n.getOrder(),
assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null,
assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null,
fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets)));
}
@@ -139,14 +142,28 @@ public class FoundryExportService {
enemies.add(new FoundryBundle.Persona(
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(),
e.getFoundryRef(),
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), e.getImageValues(), assets)));
}
List<FoundryBundle.RandomTable> randomTables = new ArrayList<>();
for (RandomTableJpaEntity t : randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
List<FoundryBundle.RandomTableEntry> entries = new ArrayList<>();
if (t.getEntries() != null) {
for (RandomTableEntryJpaEntity en : t.getEntries()) {
entries.add(new FoundryBundle.RandomTableEntry(
en.getMinRoll(), en.getMaxRoll(), en.getLabel(), en.getDetail()));
}
}
randomTables.add(new FoundryBundle.RandomTable(
str(t.getId()), t.getName(), t.getDescription(), t.getDiceFormula(), entries));
}
FoundryBundle.Campaign campaignNode = new FoundryBundle.Campaign(
str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId());
FoundryBundle.Data data = new FoundryBundle.Data(
FORMAT_VERSION, campaignNode, arcs, quests, scenes, npcs, enemies, assets.assets());
FORMAT_VERSION, campaignNode, arcs, quests, scenes, npcs, enemies, randomTables, assets.assets());
Map<String, Integer> counts = new LinkedHashMap<>();
counts.put("arcs", arcs.size());
@@ -154,6 +171,7 @@ public class FoundryExportService {
counts.put("scenes", scenes.size());
counts.put("npcs", npcs.size());
counts.put("enemies", enemies.size());
counts.put("randomTables", randomTables.size());
counts.put("assets", assets.assets().size());
FoundryBundle.Manifest manifest = new FoundryBundle.Manifest(

View File

@@ -0,0 +1,41 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.EnemyService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* Import dans le bestiaire d'une campagne d'un catalogue de monstres exporté depuis
* Foundry (compendiums). On ne stocke que nom + référence (UUID de compendium) :
* les stats restent côté Foundry et sont ré-instanciées à l'export.
*
* {@code POST /api/campaigns/{campaignId}/import-foundry-monsters}
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}")
public class FoundryMonsterImportController {
private final EnemyService enemyService;
public FoundryMonsterImportController(EnemyService enemyService) {
this.enemyService = enemyService;
}
/** Format du catalogue produit par le module Foundry. */
public record MonsterCatalog(String system, List<MonsterEntry> monsters) {}
public record MonsterEntry(String name, String uuid, String img) {}
@PostMapping("/import-foundry-monsters")
public ResponseEntity<EnemyService.MonsterImportResult> importMonsters(
@PathVariable String campaignId,
@RequestBody MonsterCatalog catalog) {
List<EnemyService.MonsterImport> monsters = (catalog.monsters() == null ? List.<MonsterEntry>of() : catalog.monsters())
.stream()
.map(m -> new EnemyService.MonsterImport(m.name(), m.uuid()))
.toList();
return ResponseEntity.ok(enemyService.importFoundryMonsters(campaignId, monsters));
}
}

View File

@@ -0,0 +1,9 @@
-- ============================================================================
-- V3 : reference Foundry sur les ennemis (import depuis un compendium).
-- ============================================================================
-- Un ennemi importe d'un compendium Foundry porte l'UUID de l'acteur source
-- (ex. Compendium.nimble.monsters.Actor.abc123). Permet, a l'export Foundry, de
-- poser un token du VRAI acteur (stats natives), sans recopier les stats.
-- Nullable : un ennemi fait main n'a pas de reference.
alter table enemies add column foundry_ref varchar(512);

View File

@@ -0,0 +1,64 @@
package com.loremind.application.campaigncontext;
import com.loremind.infrastructure.persistence.entity.CampaignJpaEntity;
import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository;
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Import de monstres Foundry dans le bestiaire : upsert (dédup par foundryRef).
*/
@SpringBootTest
@Transactional
class EnemyServiceMonsterImportTest {
@Autowired private EnemyService enemyService;
@Autowired private CampaignJpaRepository campaignRepo;
@Autowired private EnemyJpaRepository enemyRepo;
@Test
void importFoundryMonsters_createsThenUpsertsByFoundryRef() {
Long cid = campaignRepo.save(
CampaignJpaEntity.builder().name("Camp").arcsCount(0).build()).getId();
var r1 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of(
new EnemyService.MonsterImport("Goblin", "Compendium.nimble.monsters.Actor.g1"),
new EnemyService.MonsterImport("Orc", "Compendium.nimble.monsters.Actor.o1")));
assertEquals(2, r1.created());
assertEquals(0, r1.updated());
assertEquals(2, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size());
// Réimport : g1 déjà connu (renommé) + un nouveau (k1). Pas de doublon pour g1.
var r2 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of(
new EnemyService.MonsterImport("Goblin Boss", "Compendium.nimble.monsters.Actor.g1"),
new EnemyService.MonsterImport("Kobold", "Compendium.nimble.monsters.Actor.k1")));
assertEquals(1, r2.created());
assertEquals(1, r2.updated());
var all = enemyRepo.findByCampaignIdOrderByOrderAsc(cid);
assertEquals(3, all.size());
assertTrue(all.stream().anyMatch(e ->
"Goblin Boss".equals(e.getName())
&& "Compendium.nimble.monsters.Actor.g1".equals(e.getFoundryRef())));
}
@Test
void importFoundryMonsters_ignoresBlankRefOrName() {
Long cid = campaignRepo.save(
CampaignJpaEntity.builder().name("Camp2").arcsCount(0).build()).getId();
var r = enemyService.importFoundryMonsters(String.valueOf(cid), List.of(
new EnemyService.MonsterImport("", "Compendium.x.Actor.a"),
new EnemyService.MonsterImport("SansRef", " "),
new EnemyService.MonsterImport("Valide", "Compendium.x.Actor.b")));
assertEquals(1, r.created());
assertEquals(1, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size());
}
}

View File

@@ -32,6 +32,7 @@ class FoundryExportServiceTest {
@Autowired private GameSystemJpaRepository gameSystemRepo;
@Autowired private ImageJpaRepository imageRepo;
@Autowired private StoredFileJpaRepository fileRepo;
@Autowired private RandomTableJpaRepository tableRepo;
@Test
void buildBundle_assemblesFullCampaign_withBattlemapAssetsAndResolvedNpcFields() {
@@ -76,6 +77,19 @@ class FoundryExportServiceTest {
.keyValueValues(Map.of("Caracs", Map.of("FOR", "16")))
.build());
// Table aléatoire (1d6) avec 2 entrées -> RollTable Foundry.
RandomTableJpaEntity table = RandomTableJpaEntity.builder()
.campaignId(camp.getId()).name("Rencontres").description("Sur la route").diceFormula("1d6").order(0)
.build();
RandomTableEntryJpaEntity e1 = new RandomTableEntryJpaEntity();
e1.setMinRoll(1); e1.setMaxRoll(3); e1.setLabel("Gobelins"); e1.setDetail("3d4"); e1.setPosition(0);
e1.setRandomTable(table);
RandomTableEntryJpaEntity e2 = new RandomTableEntryJpaEntity();
e2.setMinRoll(4); e2.setMaxRoll(6); e2.setLabel("Rien"); e2.setPosition(1);
e2.setRandomTable(table);
table.setEntries(List.of(e1, e2));
tableRepo.save(table);
FoundryExportService.BuiltBundle bundle = service.buildBundle(String.valueOf(camp.getId()), "2026-06-25T00:00:00Z");
FoundryBundle.Data data = bundle.data();
@@ -119,10 +133,21 @@ class FoundryExportServiceTest {
assertEquals("FOR", caracs.entries().get(0).label());
assertEquals("16", caracs.entries().get(0).value());
// Table aléatoire -> RollTable (formule + entrées avec intervalles).
assertEquals(1, data.randomTables().size());
FoundryBundle.RandomTable rt = data.randomTables().get(0);
assertEquals("Rencontres", rt.name());
assertEquals("1d6", rt.diceFormula());
assertEquals(2, rt.entries().size());
assertEquals("Gobelins", rt.entries().get(0).label());
assertEquals(1, rt.entries().get(0).minRoll());
assertEquals(3, rt.entries().get(0).maxRoll());
// Manifest
assertEquals("1.0", bundle.manifest().formatVersion());
assertEquals("plain", bundle.manifest().contentFormat());
assertEquals(1, bundle.manifest().counts().get("scenes"));
assertEquals(1, bundle.manifest().counts().get("randomTables"));
assertEquals(3, bundle.manifest().counts().get("assets"));
}