Ajout de drag'n drop là ou c'est possible ; ajout de l'import des ennemis depuis foundryVTT
All checks were successful
All checks were successful
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ public record ContentExport(
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
Long campaignId,
|
||||
String foundryRef,
|
||||
int order
|
||||
) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user