Possibilité d'importer un template de monstre depuis le systeme qu'on utilise sur Foundry pour forcer le template. Lors de l'export, le monstre est automatiquement adapté comme ça.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m25s
Build & Push Images / build (web) (push) Successful in 1m54s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 17s

à l'import des monstres depuis foundry : on garde la structure originelle utilisée sur foundry également
This commit is contained in:
2026-06-25 16:50:27 +02:00
parent 9bd66613b6
commit b999aa54ad
45 changed files with 590 additions and 41 deletions

View File

@@ -83,8 +83,17 @@ public class EnemyService {
// --- 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) {}
/** Un monstre du catalogue Foundry : nom + référence + snapshot de stats + dossier. */
public record MonsterImport(String name, String foundryRef, Map<String, String> stats, String folder) {}
/**
* Dossier LoreMind d'un monstre importé : on conserve l'arborescence Foundry
* sous un dossier racine « Foundry » ("Foundry/Briarban", "Foundry" si aucun).
*/
private static String foundryFolder(String path) {
String p = path == null ? "" : path.trim();
return p.isEmpty() ? "Foundry" : "Foundry/" + p;
}
public record MonsterImportResult(int created, int updated) {}
@@ -108,15 +117,21 @@ public class EnemyService {
|| m.name() == null || m.name().isBlank()) {
continue;
}
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
String folder = foundryFolder(m.folder());
Enemy ex = byRef.get(m.foundryRef());
if (ex != null) {
ex.setName(m.name());
ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(folder); // ré-aligne sur l'arborescence Foundry
enemyRepository.save(ex);
updated++;
} else {
Enemy saved = enemyRepository.save(Enemy.builder()
.name(m.name())
.foundryRef(m.foundryRef())
.foundryStats(stats)
.folder(folder)
.campaignId(campaignId)
.order(order++)
.values(new HashMap<>())

View File

@@ -4,11 +4,15 @@ import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Service
public class GameSystemService {
@@ -58,6 +62,7 @@ public class GameSystemService {
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String foundryActorType,
String author,
boolean isPublic
) {}
@@ -67,6 +72,7 @@ public class GameSystemService {
.name(data.name())
.description(data.description())
.rulesMarkdown(data.rulesMarkdown())
.foundryActorType(normalize(data.foundryActorType()))
.author(normalize(data.author()))
.isPublic(data.isPublic())
.build();
@@ -93,11 +99,41 @@ public class GameSystemService {
existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate());
existing.replaceEnemyTemplate(data.enemyTemplate());
existing.setFoundryActorType(normalize(data.foundryActorType()));
existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing);
}
/** Un champ scalaire d'une structure d'acteur Foundry importée. */
public record FoundryStructField(String path, String label, String type) {}
/**
* Remplace le template ENNEMI par une structure importée d'un système Foundry :
* chaque champ devient un TemplateField mappé à son chemin Foundry. Pose aussi le
* type d'acteur. L'utilisateur élague/renomme ensuite dans l'éditeur de template.
*/
public GameSystem importFoundryStructure(String id, String actorType, List<FoundryStructField> fields) {
GameSystem gs = gameSystemRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id));
List<TemplateField> template = new ArrayList<>();
Set<String> usedNames = new HashSet<>();
for (FoundryStructField f : fields == null ? List.<FoundryStructField>of() : fields) {
if (f.path() == null || f.path().isBlank()) continue;
String label = (f.label() != null && !f.label().isBlank()) ? f.label().trim() : f.path();
// Nom unique : libellé si libre, sinon le chemin (toujours unique).
String name = usedNames.contains(label.toLowerCase()) ? f.path() : label;
if (usedNames.contains(name.toLowerCase())) continue;
usedNames.add(name.toLowerCase());
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
template.add(new TemplateField(name, type, null, null, f.path()));
}
gs.replaceEnemyTemplate(template);
gs.setFoundryActorType(normalize(actorType));
return gameSystemRepository.save(gs);
}
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
}

View File

@@ -55,6 +55,14 @@ public class Enemy {
*/
private String foundryRef;
/**
* Instantané (figé) des stats de l'acteur Foundry d'origine, aplati en
* cle->valeur, pour AFFICHAGE en prep dans LoreMind. Dependant du systeme,
* non synchronise (les stats vivantes restent cote Foundry). Jamais null apres
* construction.
*/
private Map<String, String> foundryStats;
/** Ordre d'affichage dans la liste. */
private int order;
@@ -75,4 +83,9 @@ public class Enemy {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
public Map<String, String> getFoundryStats() {
if (foundryStats == null) foundryStats = new HashMap<>();
return foundryStats;
}
}

View File

@@ -58,6 +58,13 @@ public class GameSystem {
*/
private List<TemplateField> enemyTemplate;
/**
* Type d'acteur Foundry à créer pour les ennemis maison de ce système
* (ex: {@code npc}, {@code monster}). Renseigné par l'import de structure Foundry ;
* couplé aux {@code foundryPath} des champs de {@link #enemyTemplate}. Nullable.
*/
private String foundryActorType;
/** Auteur déclaré — futur marketplace. Nullable. */
private String author;

View File

@@ -37,14 +37,26 @@ public class TemplateField {
*/
private List<String> labels;
/**
* Chemin Foundry de ce champ (ex: {@code attributes.hp.value}) quand le template
* est calqué sur un système Foundry importé. Permet, a l'export, de construire un
* acteur typé en posant {@code system.<foundryPath> = valeur}. Null = non mappé.
*/
private String foundryPath;
/** Constructeur de retrocompat : type seul, layout/labels=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null, null);
this(name, type, null, null, null);
}
/** Constructeur de retrocompat : type + layout, labels=null. */
public TemplateField(String name, FieldType type, ImageLayout layout) {
this(name, type, layout, null);
this(name, type, layout, null, null);
}
/** Constructeur de retrocompat (4 args) : sans foundryPath. */
public TemplateField(String name, FieldType type, ImageLayout layout, List<String> labels) {
this(name, type, layout, labels, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */

View File

@@ -67,6 +67,11 @@ public class EnemyJpaEntity {
@Column(name = "foundry_ref", length = 512)
private String foundryRef;
/** Instantané figé des stats Foundry (aplati cle->valeur), JSON en TEXT. */
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "foundry_stats", columnDefinition = "TEXT")
private Map<String, String> foundryStats;
@Column(name = "\"order\"", nullable = false)
private int order;

View File

@@ -51,6 +51,10 @@ public class GameSystemJpaEntity {
@Column(name = "enemy_template", columnDefinition = "TEXT")
private List<TemplateField> enemyTemplate;
/** Type d'acteur Foundry pour les ennemis maison (import de structure). Nullable. */
@Column(name = "foundry_actor_type")
private String foundryActorType;
@Column
private String author;

View File

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

View File

@@ -70,6 +70,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.enemyTemplate(e.getEnemyTemplate() != null
? new java.util.ArrayList<>(e.getEnemyTemplate())
: new java.util.ArrayList<>())
.foundryActorType(e.getFoundryActorType())
.author(e.getAuthor())
.isPublic(e.isPublic())
.createdAt(e.getCreatedAt())
@@ -93,6 +94,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.enemyTemplate(g.getEnemyTemplate() != null
? new java.util.ArrayList<>(g.getEnemyTemplate())
: new java.util.ArrayList<>())
.foundryActorType(g.getFoundryActorType())
.author(g.getAuthor())
.isPublic(g.isPublic())
.createdAt(g.getCreatedAt())

View File

@@ -293,7 +293,7 @@ public class ExportService {
private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) {
return new ContentExport.GameSystemDto(e.getId(), e.getName(), e.getDescription(),
e.getRulesMarkdown(), e.getCharacterTemplate(), e.getNpcTemplate(),
e.getEnemyTemplate(), e.getAuthor(), e.isPublic());
e.getEnemyTemplate(), e.getFoundryActorType(), e.getAuthor(), e.isPublic());
}
private ContentExport.LoreDto toLoreDto(LoreJpaEntity e) {
@@ -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.getFoundryRef(), e.getOrder());
e.getKeyValueValues(), e.getCampaignId(), e.getFoundryRef(), e.getFoundryStats(), e.getOrder());
}
private ContentExport.ItemCatalogDto toItemCatalogDto(ItemCatalogJpaEntity e) {

View File

@@ -136,6 +136,7 @@ public class ImportService {
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());
@@ -336,6 +337,7 @@ public class ImportService {
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());
}

View File

@@ -58,6 +58,7 @@ public record ContentExport(
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String foundryActorType,
String author,
boolean isPublic
) {}
@@ -210,6 +211,7 @@ public record ContentExport(
Map<String, Map<String, String>> keyValueValues,
Long campaignId,
String foundryRef,
Map<String, String> foundryStats,
int order
) {}

View File

@@ -77,9 +77,15 @@ public final class FoundryBundle {
public record Persona(
String id, String name, String folder, int order,
String portraitAssetId, String headerAssetId, String level,
String foundryRef, List<Field> fields
String foundryRef, FoundryActor foundryActor, List<Field> fields
) {}
/**
* Acteur Foundry typé à créer pour un ennemi MAISON (sans référence) : type
* d'acteur + données system pré-construites depuis les champs mappés du template.
*/
public record FoundryActor(String type, Map<String, Object> system) {}
/** Champ de fiche resolu : {type, label} + selon le type value | entries | assetIds. */
public record Field(String type, String label, String value, List<Entry> entries, List<String> assetIds) {}

View File

@@ -101,6 +101,7 @@ public class FoundryExportService {
List<TemplateField> npcTemplate = resolveTemplate(campaign.getGameSystemId(), true);
List<TemplateField> enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false);
String foundryActorType = resolveActorType(campaign.getGameSystemId());
AssetRegistry assets = new AssetRegistry();
@@ -133,7 +134,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, null,
assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null,
fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets)));
}
@@ -143,6 +144,7 @@ public class FoundryExportService {
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(),
e.getFoundryRef(),
buildFoundryActor(e, enemyTemplate, foundryActorType),
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), e.getImageValues(), assets)));
}
@@ -284,6 +286,65 @@ public class FoundryExportService {
}
}
private String resolveActorType(String gameSystemId) {
if (gameSystemId == null || gameSystemId.isBlank()) return null;
try {
return gameSystemRepo.findById(Long.parseLong(gameSystemId))
.map(GameSystemJpaEntity::getFoundryActorType)
.orElse(null);
} catch (NumberFormatException ex) {
return null;
}
}
/**
* Construit l'acteur Foundry typé d'un ennemi MAISON (sans référence) : pose
* {@code system.<foundryPath> = valeur} pour chaque champ mappé non vide. Null si
* pas de type d'acteur, pas de champ mappé, ou si l'ennemi a déjà une référence
* (auquel cas le vrai acteur du compendium est utilisé à la place).
*/
private FoundryBundle.FoundryActor buildFoundryActor(EnemyJpaEntity e, List<TemplateField> template, String actorType) {
if (actorType == null || actorType.isBlank() || template == null) return null;
if (e.getFoundryRef() != null && !e.getFoundryRef().isBlank()) return null;
Map<String, Object> system = new LinkedHashMap<>();
Map<String, String> values = e.getValues();
boolean any = false;
for (TemplateField f : template) {
if (f == null || f.getName() == null) continue;
String path = f.getFoundryPath();
if (path == null || path.isBlank()) continue;
String raw = values != null ? values.get(f.getName()) : null;
if (raw == null || raw.isBlank()) continue;
setPath(system, path, f.getType() == FieldType.NUMBER ? parseNumber(raw) : raw);
any = true;
}
return any ? new FoundryBundle.FoundryActor(actorType, system) : null;
}
/** Pose une valeur dans un objet imbriqué selon un chemin pointé ("a.b.c"). */
@SuppressWarnings("unchecked")
private static void setPath(Map<String, Object> root, String path, Object value) {
String[] parts = path.split("\\.");
Map<String, Object> cur = root;
for (int i = 0; i < parts.length - 1; i++) {
Object next = cur.get(parts[i]);
if (!(next instanceof Map)) {
next = new LinkedHashMap<String, Object>();
cur.put(parts[i], next);
}
cur = (Map<String, Object>) next;
}
cur.put(parts[parts.length - 1], value);
}
/** Convertit en Integer ou Double si possible (pour que Foundry reçoive un nombre). */
private static Object parseNumber(String s) {
String t = s.trim();
try { return Integer.valueOf(t); } catch (NumberFormatException ignored) { /* fallthrough */ }
try { return Double.valueOf(t.replace(',', '.')); } catch (NumberFormatException ignored) { /* fallthrough */ }
return s;
}
private List<FoundryBundle.Field> fields(List<TemplateField> template,
Map<String, String> values,
Map<String, Map<String, String>> keyValueValues,

View File

@@ -26,7 +26,8 @@ public class FoundryMonsterImportController {
/** 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) {}
public record MonsterEntry(String name, String uuid, String type, String img,
java.util.Map<String, String> stats, String folder) {}
@PostMapping("/import-foundry-monsters")
public ResponseEntity<EnemyService.MonsterImportResult> importMonsters(
@@ -34,7 +35,7 @@ public class FoundryMonsterImportController {
@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()))
.map(m -> new EnemyService.MonsterImport(m.name(), m.uuid(), m.stats(), m.folder()))
.toList();
return ResponseEntity.ok(enemyService.importFoundryMonsters(campaignId, monsters));
}

View File

@@ -261,8 +261,29 @@ public class GameSystemController {
templateFieldMapper.toDomainList(dto.getCharacterTemplate()),
templateFieldMapper.toDomainList(dto.getNpcTemplate()),
templateFieldMapper.toDomainList(dto.getEnemyTemplate()),
dto.getFoundryActorType(),
dto.getAuthor(),
dto.isPublic()
);
}
/**
* Importe une structure d'acteur Foundry (exportée par le module) : remplace le
* template ENNEMI par les champs mappés + pose le type d'acteur. Renvoie le système
* mis à jour (le front rafraîchit l'éditeur de template).
*/
@PostMapping("/{id}/import-foundry-structure")
public ResponseEntity<GameSystemDTO> importFoundryStructure(
@PathVariable String id, @RequestBody FoundryStructureRequest req) {
List<GameSystemService.FoundryStructField> fields =
(req.fields() == null ? List.<StructFieldDto>of() : req.fields()).stream()
.map(f -> new GameSystemService.FoundryStructField(f.path(), f.label(), f.type()))
.collect(Collectors.toList());
GameSystem updated = gameSystemService.importFoundryStructure(id, req.actorType(), fields);
return ResponseEntity.ok(gameSystemMapper.toDTO(updated));
}
public record FoundryStructureRequest(String system, String actorType, List<StructFieldDto> fields) {}
public record StructFieldDto(String path, String label, String type) {}
}

View File

@@ -20,6 +20,8 @@ public class GameSystemDTO {
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
private List<TemplateFieldDTO> enemyTemplate = new ArrayList<>();
/** Type d'acteur Foundry pour l'export des ennemis maison (import de structure). */
private String foundryActorType;
private String author;
private boolean isPublic;
}

View File

@@ -26,13 +26,21 @@ public class TemplateFieldDTO {
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
private List<String> labels;
/** Chemin Foundry du champ (mapping pour l'export d'acteur typé). Nullable. */
private String foundryPath;
/** Retrocompat : constructeur sans foundryPath. */
public TemplateFieldDTO(String name, String type, String layout, List<String> labels) {
this(name, type, layout, labels, null);
}
/** Retrocompat : constructeur sans labels. */
public TemplateFieldDTO(String name, String type, String layout) {
this(name, type, layout, null);
this(name, type, layout, null, null);
}
/** Retrocompat : constructeur sans layout ni labels. */
public TemplateFieldDTO(String name, String type) {
this(name, type, null, null);
this(name, type, null, null, null);
}
}

View File

@@ -23,6 +23,7 @@ public class GameSystemMapper {
dto.setCharacterTemplate(fieldMapper.toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(fieldMapper.toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(fieldMapper.toDTOList(g.getEnemyTemplate()));
dto.setFoundryActorType(g.getFoundryActorType());
dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic());
return dto;
@@ -38,6 +39,7 @@ public class GameSystemMapper {
.characterTemplate(fieldMapper.toDomainList(dto.getCharacterTemplate()))
.npcTemplate(fieldMapper.toDomainList(dto.getNpcTemplate()))
.enemyTemplate(fieldMapper.toDomainList(dto.getEnemyTemplate()))
.foundryActorType(dto.getFoundryActorType())
.author(dto.getAuthor())
.isPublic(dto.isPublic())
.build();

View File

@@ -33,7 +33,7 @@ public class TemplateFieldMapper {
&& field.getLabels() != null) {
labels = new ArrayList<>(field.getLabels());
}
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels, field.getFoundryPath());
}
public TemplateField toDomain(TemplateFieldDTO dto) {
@@ -58,7 +58,7 @@ public class TemplateFieldMapper {
if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) {
labels = new ArrayList<>(dto.getLabels());
}
return new TemplateField(dto.getName(), type, layout, labels);
return new TemplateField(dto.getName(), type, layout, labels, dto.getFoundryPath());
}
/** Mappe une liste de champs domaine → DTO ({@code null} → liste vide). */

View File

@@ -0,0 +1,8 @@
-- ============================================================================
-- V4 : instantané des stats Foundry sur les ennemis (affichage en prep).
-- ============================================================================
-- A l'import d'un monstre Foundry, on stocke un snapshot APLATI (cle->valeur) de
-- ses stats systeme, juste pour l'affichage cote LoreMind. JSON en TEXT (meme
-- mecanique que field_values). Nullable, figé (non synchronise avec Foundry).
alter table enemies add column foundry_stats TEXT;

View File

@@ -0,0 +1,8 @@
-- ============================================================================
-- V5 : type d'acteur Foundry sur le GameSystem (export d'ennemis maison typés).
-- ============================================================================
-- Renseigne par l'import d'une structure d'acteur Foundry. Couple aux foundryPath
-- des champs du template ennemi : a l'export, un ennemi maison devient un acteur de
-- ce type avec system.<foundryPath> = valeur. Nullable.
alter table game_systems add column foundry_actor_type varchar(64);

View File

@@ -9,6 +9,7 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@@ -29,18 +30,32 @@ class EnemyServiceMonsterImportTest {
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")));
new EnemyService.MonsterImport("Goblin", "Compendium.nimble.monsters.Actor.g1", Map.of("level", "1"), "Briarban"),
new EnemyService.MonsterImport("Orc", "Compendium.nimble.monsters.Actor.o1", Map.of(), null)));
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.
// Snapshot de stats conservé + arborescence Foundry sous "Foundry/".
var goblin = enemyRepo.findByCampaignIdOrderByOrderAsc(cid).stream()
.filter(e -> "Compendium.nimble.monsters.Actor.g1".equals(e.getFoundryRef()))
.findFirst().orElseThrow();
assertEquals("1", goblin.getFoundryStats().get("level"));
assertEquals("Foundry/Briarban", goblin.getFolder());
var orc = enemyRepo.findByCampaignIdOrderByOrderAsc(cid).stream()
.filter(e -> "Compendium.nimble.monsters.Actor.o1".equals(e.getFoundryRef()))
.findFirst().orElseThrow();
assertEquals("Foundry", orc.getFolder()); // sans dossier Foundry -> racine
// Réimport : g1 déjà connu (renommé + redossiérisé) + un nouveau (k1). Pas de doublon.
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")));
new EnemyService.MonsterImport("Goblin Boss", "Compendium.nimble.monsters.Actor.g1", Map.of(), "Briarban/Bosses"),
new EnemyService.MonsterImport("Kobold", "Compendium.nimble.monsters.Actor.k1", Map.of(), "Kobolds")));
assertEquals(1, r2.created());
assertEquals(1, r2.updated());
assertEquals("Foundry/Briarban/Bosses", enemyRepo.findByCampaignIdOrderByOrderAsc(cid).stream()
.filter(e -> "Compendium.nimble.monsters.Actor.g1".equals(e.getFoundryRef()))
.findFirst().orElseThrow().getFolder());
var all = enemyRepo.findByCampaignIdOrderByOrderAsc(cid);
assertEquals(3, all.size());
@@ -55,9 +70,9 @@ class EnemyServiceMonsterImportTest {
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")));
new EnemyService.MonsterImport("", "Compendium.x.Actor.a", Map.of(), null),
new EnemyService.MonsterImport("SansRef", " ", Map.of(), null),
new EnemyService.MonsterImport("Valide", "Compendium.x.Actor.b", Map.of(), null)));
assertEquals(1, r.created());
assertEquals(1, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size());
}

View File

@@ -0,0 +1,77 @@
package com.loremind.infrastructure.transfer.foundry;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
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 java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pont « structure système Foundry » : import d'une structure -> template ennemi
* mappé (foundryPath) + type d'acteur, puis export d'un ennemi MAISON en acteur typé
* ({@code system.<chemin> = valeur}).
*/
@SpringBootTest
@Transactional
class GameSystemStructureExportTest {
@Autowired private GameSystemService gameSystemService;
@Autowired private GameSystemJpaRepository gameSystemRepo;
@Autowired private FoundryExportService exportService;
@Autowired private CampaignJpaRepository campaignRepo;
@Autowired private EnemyJpaRepository enemyRepo;
@Test
void importStructureThenExportTypedActor() {
GameSystemJpaEntity gs = gameSystemRepo.save(
GameSystemJpaEntity.builder().name("Nimble-like").isPublic(false).build());
// Import de structure : 2 champs scalaires mappés + type d'acteur "npc".
GameSystem updated = gameSystemService.importFoundryStructure(String.valueOf(gs.getId()), "npc", List.of(
new GameSystemService.FoundryStructField("attributes.hp.value", "PV", "number"),
new GameSystemService.FoundryStructField("abilities.str.value", "FOR", "number")));
assertEquals("npc", updated.getFoundryActorType());
assertEquals(2, updated.getEnemyTemplate().size());
var pv = updated.getEnemyTemplate().stream().filter(f -> "PV".equals(f.getName())).findFirst().orElseThrow();
assertEquals("attributes.hp.value", pv.getFoundryPath());
assertEquals(FieldType.NUMBER, pv.getType());
// Campagne adossée au système + un ennemi MAISON (sans référence) rempli.
CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder()
.name("Camp").arcsCount(0).gameSystemId(String.valueOf(gs.getId())).build());
enemyRepo.save(EnemyJpaEntity.builder()
.campaignId(camp.getId()).name("Bandit maison").order(0)
.values(Map.of("PV", "11", "FOR", "3"))
.build());
FoundryExportService.BuiltBundle bundle =
exportService.buildBundle(String.valueOf(camp.getId()), "2026-06-25T00:00:00Z");
var enemy = bundle.data().enemies().stream()
.filter(e -> "Bandit maison".equals(e.name())).findFirst().orElseThrow();
assertNotNull(enemy.foundryActor(), "un ennemi maison mappé doit porter un foundryActor");
assertEquals("npc", enemy.foundryActor().type());
// system imbriqué : attributes.hp.value = 11, abilities.str.value = 3.
Map<String, Object> system = enemy.foundryActor().system();
assertEquals(11, nested(system, "attributes", "hp", "value"));
assertEquals(3, nested(system, "abilities", "str", "value"));
}
@SuppressWarnings("unchecked")
private static Object nested(Map<String, Object> root, String... path) {
Object cur = root;
for (String p : path) cur = ((Map<String, Object>) cur).get(p);
return cur;
}
}