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 ------------------- // --- Import de monstres depuis un compendium Foundry -------------------
/** Un monstre du catalogue Foundry : nom + référence (UUID de compendium). */ /** Un monstre du catalogue Foundry : nom + référence + snapshot de stats + dossier. */
public record MonsterImport(String name, String foundryRef) {} 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) {} public record MonsterImportResult(int created, int updated) {}
@@ -108,15 +117,21 @@ public class EnemyService {
|| m.name() == null || m.name().isBlank()) { || m.name() == null || m.name().isBlank()) {
continue; 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()); Enemy ex = byRef.get(m.foundryRef());
if (ex != null) { if (ex != null) {
ex.setName(m.name()); ex.setName(m.name());
ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(folder); // ré-aligne sur l'arborescence Foundry
enemyRepository.save(ex); enemyRepository.save(ex);
updated++; updated++;
} else { } else {
Enemy saved = enemyRepository.save(Enemy.builder() Enemy saved = enemyRepository.save(Enemy.builder()
.name(m.name()) .name(m.name())
.foundryRef(m.foundryRef()) .foundryRef(m.foundryRef())
.foundryStats(stats)
.folder(folder)
.campaignId(campaignId) .campaignId(campaignId)
.order(order++) .order(order++)
.values(new HashMap<>()) .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.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter; import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
@Service @Service
public class GameSystemService { public class GameSystemService {
@@ -58,6 +62,7 @@ public class GameSystemService {
List<TemplateField> characterTemplate, List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate, List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate, List<TemplateField> enemyTemplate,
String foundryActorType,
String author, String author,
boolean isPublic boolean isPublic
) {} ) {}
@@ -67,6 +72,7 @@ public class GameSystemService {
.name(data.name()) .name(data.name())
.description(data.description()) .description(data.description())
.rulesMarkdown(data.rulesMarkdown()) .rulesMarkdown(data.rulesMarkdown())
.foundryActorType(normalize(data.foundryActorType()))
.author(normalize(data.author())) .author(normalize(data.author()))
.isPublic(data.isPublic()) .isPublic(data.isPublic())
.build(); .build();
@@ -93,11 +99,41 @@ public class GameSystemService {
existing.replaceCharacterTemplate(data.characterTemplate()); existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate()); existing.replaceNpcTemplate(data.npcTemplate());
existing.replaceEnemyTemplate(data.enemyTemplate()); existing.replaceEnemyTemplate(data.enemyTemplate());
existing.setFoundryActorType(normalize(data.foundryActorType()));
existing.setAuthor(normalize(data.author())); existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic()); existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing); 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) { public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id); gameSystemRepository.deleteById(id);
} }

View File

@@ -55,6 +55,14 @@ public class Enemy {
*/ */
private String foundryRef; 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. */ /** Ordre d'affichage dans la liste. */
private int order; private int order;
@@ -75,4 +83,9 @@ public class Enemy {
if (keyValueValues == null) keyValueValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues; 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; 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. */ /** Auteur déclaré — futur marketplace. Nullable. */
private String author; private String author;

View File

@@ -37,14 +37,26 @@ public class TemplateField {
*/ */
private List<String> labels; 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. */ /** Constructeur de retrocompat : type seul, layout/labels=null. */
public TemplateField(String name, FieldType type) { public TemplateField(String name, FieldType type) {
this(name, type, null, null); this(name, type, null, null, null);
} }
/** Constructeur de retrocompat : type + layout, labels=null. */ /** Constructeur de retrocompat : type + layout, labels=null. */
public TemplateField(String name, FieldType type, ImageLayout layout) { 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). */ /** 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) @Column(name = "foundry_ref", length = 512)
private String foundryRef; 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) @Column(name = "\"order\"", nullable = false)
private int order; private int order;

View File

@@ -51,6 +51,10 @@ public class GameSystemJpaEntity {
@Column(name = "enemy_template", columnDefinition = "TEXT") @Column(name = "enemy_template", columnDefinition = "TEXT")
private List<TemplateField> enemyTemplate; 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 @Column
private String author; private String author;

View File

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

View File

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

View File

@@ -293,7 +293,7 @@ public class ExportService {
private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) { private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) {
return new ContentExport.GameSystemDto(e.getId(), e.getName(), e.getDescription(), return new ContentExport.GameSystemDto(e.getId(), e.getName(), e.getDescription(),
e.getRulesMarkdown(), e.getCharacterTemplate(), e.getNpcTemplate(), 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) { private ContentExport.LoreDto toLoreDto(LoreJpaEntity e) {
@@ -364,7 +364,7 @@ public class ExportService {
private ContentExport.EnemyDto toEnemyDto(EnemyJpaEntity e) { private ContentExport.EnemyDto toEnemyDto(EnemyJpaEntity e) {
return new ContentExport.EnemyDto(e.getId(), e.getName(), e.getLevel(), e.getFolder(), return new ContentExport.EnemyDto(e.getId(), e.getName(), e.getLevel(), e.getFolder(),
e.getPortraitImageId(), e.getHeaderImageId(), e.getValues(), e.getImageValues(), 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) { private ContentExport.ItemCatalogDto toItemCatalogDto(ItemCatalogJpaEntity e) {

View File

@@ -136,6 +136,7 @@ public class ImportService {
e.setCharacterTemplate(d.characterTemplate()); e.setCharacterTemplate(d.characterTemplate());
e.setNpcTemplate(d.npcTemplate()); e.setNpcTemplate(d.npcTemplate());
e.setEnemyTemplate(d.enemyTemplate()); e.setEnemyTemplate(d.enemyTemplate());
e.setFoundryActorType(d.foundryActorType());
e.setAuthor(d.author()); e.setAuthor(d.author());
e.setPublic(d.isPublic()); e.setPublic(d.isPublic());
gameSystemMap.put(d.id(), gameSystemRepo.save(e).getId()); gameSystemMap.put(d.id(), gameSystemRepo.save(e).getId());
@@ -336,6 +337,7 @@ public class ImportService {
e.setKeyValueValues(d.keyValueValues()); e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setFoundryRef(d.foundryRef()); // ref externe Foundry : conservee telle quelle e.setFoundryRef(d.foundryRef()); // ref externe Foundry : conservee telle quelle
e.setFoundryStats(d.foundryStats());
e.setOrder(d.order()); e.setOrder(d.order());
enemyMap.put(d.id(), enemyRepo.save(e).getId()); enemyMap.put(d.id(), enemyRepo.save(e).getId());
} }

View File

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

View File

@@ -77,9 +77,15 @@ public final class FoundryBundle {
public record Persona( public record Persona(
String id, String name, String folder, int order, String id, String name, String folder, int order,
String portraitAssetId, String headerAssetId, String level, 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. */ /** 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) {} 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> npcTemplate = resolveTemplate(campaign.getGameSystemId(), true);
List<TemplateField> enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false); List<TemplateField> enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false);
String foundryActorType = resolveActorType(campaign.getGameSystemId());
AssetRegistry assets = new AssetRegistry(); AssetRegistry assets = new AssetRegistry();
@@ -133,7 +134,7 @@ public class FoundryExportService {
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
npcs.add(new FoundryBundle.Persona( npcs.add(new FoundryBundle.Persona(
str(n.getId()), n.getName(), n.getFolder(), n.getOrder(), 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))); 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(), str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(), assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(),
e.getFoundryRef(), e.getFoundryRef(),
buildFoundryActor(e, enemyTemplate, foundryActorType),
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), e.getImageValues(), assets))); 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, private List<FoundryBundle.Field> fields(List<TemplateField> template,
Map<String, String> values, Map<String, String> values,
Map<String, Map<String, String>> keyValueValues, Map<String, Map<String, String>> keyValueValues,

View File

@@ -26,7 +26,8 @@ public class FoundryMonsterImportController {
/** Format du catalogue produit par le module Foundry. */ /** Format du catalogue produit par le module Foundry. */
public record MonsterCatalog(String system, List<MonsterEntry> monsters) {} 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") @PostMapping("/import-foundry-monsters")
public ResponseEntity<EnemyService.MonsterImportResult> importMonsters( public ResponseEntity<EnemyService.MonsterImportResult> importMonsters(
@@ -34,7 +35,7 @@ public class FoundryMonsterImportController {
@RequestBody MonsterCatalog catalog) { @RequestBody MonsterCatalog catalog) {
List<EnemyService.MonsterImport> monsters = (catalog.monsters() == null ? List.<MonsterEntry>of() : catalog.monsters()) List<EnemyService.MonsterImport> monsters = (catalog.monsters() == null ? List.<MonsterEntry>of() : catalog.monsters())
.stream() .stream()
.map(m -> new EnemyService.MonsterImport(m.name(), m.uuid())) .map(m -> new EnemyService.MonsterImport(m.name(), m.uuid(), m.stats(), m.folder()))
.toList(); .toList();
return ResponseEntity.ok(enemyService.importFoundryMonsters(campaignId, monsters)); return ResponseEntity.ok(enemyService.importFoundryMonsters(campaignId, monsters));
} }

View File

@@ -261,8 +261,29 @@ public class GameSystemController {
templateFieldMapper.toDomainList(dto.getCharacterTemplate()), templateFieldMapper.toDomainList(dto.getCharacterTemplate()),
templateFieldMapper.toDomainList(dto.getNpcTemplate()), templateFieldMapper.toDomainList(dto.getNpcTemplate()),
templateFieldMapper.toDomainList(dto.getEnemyTemplate()), templateFieldMapper.toDomainList(dto.getEnemyTemplate()),
dto.getFoundryActorType(),
dto.getAuthor(), dto.getAuthor(),
dto.isPublic() 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> characterTemplate = new ArrayList<>();
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>(); private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
private List<TemplateFieldDTO> enemyTemplate = 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 String author;
private boolean isPublic; private boolean isPublic;
} }

View File

@@ -26,13 +26,21 @@ public class TemplateFieldDTO {
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */ /** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
private List<String> labels; 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. */ /** Retrocompat : constructeur sans labels. */
public TemplateFieldDTO(String name, String type, String layout) { 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. */ /** Retrocompat : constructeur sans layout ni labels. */
public TemplateFieldDTO(String name, String type) { 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.setCharacterTemplate(fieldMapper.toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(fieldMapper.toDTOList(g.getNpcTemplate())); dto.setNpcTemplate(fieldMapper.toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(fieldMapper.toDTOList(g.getEnemyTemplate())); dto.setEnemyTemplate(fieldMapper.toDTOList(g.getEnemyTemplate()));
dto.setFoundryActorType(g.getFoundryActorType());
dto.setAuthor(g.getAuthor()); dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic()); dto.setPublic(g.isPublic());
return dto; return dto;
@@ -38,6 +39,7 @@ public class GameSystemMapper {
.characterTemplate(fieldMapper.toDomainList(dto.getCharacterTemplate())) .characterTemplate(fieldMapper.toDomainList(dto.getCharacterTemplate()))
.npcTemplate(fieldMapper.toDomainList(dto.getNpcTemplate())) .npcTemplate(fieldMapper.toDomainList(dto.getNpcTemplate()))
.enemyTemplate(fieldMapper.toDomainList(dto.getEnemyTemplate())) .enemyTemplate(fieldMapper.toDomainList(dto.getEnemyTemplate()))
.foundryActorType(dto.getFoundryActorType())
.author(dto.getAuthor()) .author(dto.getAuthor())
.isPublic(dto.isPublic()) .isPublic(dto.isPublic())
.build(); .build();

View File

@@ -33,7 +33,7 @@ public class TemplateFieldMapper {
&& field.getLabels() != null) { && field.getLabels() != null) {
labels = new ArrayList<>(field.getLabels()); 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) { public TemplateField toDomain(TemplateFieldDTO dto) {
@@ -58,7 +58,7 @@ public class TemplateFieldMapper {
if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) { if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) {
labels = new ArrayList<>(dto.getLabels()); 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). */ /** 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 org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -29,18 +30,32 @@ class EnemyServiceMonsterImportTest {
CampaignJpaEntity.builder().name("Camp").arcsCount(0).build()).getId(); CampaignJpaEntity.builder().name("Camp").arcsCount(0).build()).getId();
var r1 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of( var r1 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of(
new EnemyService.MonsterImport("Goblin", "Compendium.nimble.monsters.Actor.g1"), new EnemyService.MonsterImport("Goblin", "Compendium.nimble.monsters.Actor.g1", Map.of("level", "1"), "Briarban"),
new EnemyService.MonsterImport("Orc", "Compendium.nimble.monsters.Actor.o1"))); new EnemyService.MonsterImport("Orc", "Compendium.nimble.monsters.Actor.o1", Map.of(), null)));
assertEquals(2, r1.created()); assertEquals(2, r1.created());
assertEquals(0, r1.updated()); assertEquals(0, r1.updated());
assertEquals(2, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size()); 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( var r2 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of(
new EnemyService.MonsterImport("Goblin Boss", "Compendium.nimble.monsters.Actor.g1"), new EnemyService.MonsterImport("Goblin Boss", "Compendium.nimble.monsters.Actor.g1", Map.of(), "Briarban/Bosses"),
new EnemyService.MonsterImport("Kobold", "Compendium.nimble.monsters.Actor.k1"))); new EnemyService.MonsterImport("Kobold", "Compendium.nimble.monsters.Actor.k1", Map.of(), "Kobolds")));
assertEquals(1, r2.created()); assertEquals(1, r2.created());
assertEquals(1, r2.updated()); 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); var all = enemyRepo.findByCampaignIdOrderByOrderAsc(cid);
assertEquals(3, all.size()); assertEquals(3, all.size());
@@ -55,9 +70,9 @@ class EnemyServiceMonsterImportTest {
CampaignJpaEntity.builder().name("Camp2").arcsCount(0).build()).getId(); CampaignJpaEntity.builder().name("Camp2").arcsCount(0).build()).getId();
var r = enemyService.importFoundryMonsters(String.valueOf(cid), List.of( var r = enemyService.importFoundryMonsters(String.valueOf(cid), List.of(
new EnemyService.MonsterImport("", "Compendium.x.Actor.a"), new EnemyService.MonsterImport("", "Compendium.x.Actor.a", Map.of(), null),
new EnemyService.MonsterImport("SansRef", " "), new EnemyService.MonsterImport("SansRef", " ", Map.of(), null),
new EnemyService.MonsterImport("Valide", "Compendium.x.Actor.b"))); new EnemyService.MonsterImport("Valide", "Compendium.x.Actor.b", Map.of(), null)));
assertEquals(1, r.created()); assertEquals(1, r.created());
assertEquals(1, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size()); 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;
}
}

View File

@@ -28,7 +28,7 @@
@if (g.folder) { @if (g.folder) {
<h2 class="sbl-folder"> <h2 class="sbl-folder">
<lucide-icon [img]="Folder" [size]="14"></lucide-icon> <lucide-icon [img]="Folder" [size]="14"></lucide-icon>
{{ g.folder }} {{ g.folder.split('/').join(' / ') }}
<span class="sbl-folder-count">{{ g.enemies.length }}</span> <span class="sbl-folder-count">{{ g.enemies.length }}</span>
</h2> </h2>
} @else if (groups.length > 1) { } @else if (groups.length > 1) {

View File

@@ -12,4 +12,21 @@
</div> </div>
<app-persona-view [persona]="enemy" [templateFields]="templateFields" [subtitle]="subtitle"></app-persona-view> <app-persona-view [persona]="enemy" [templateFields]="templateFields" [subtitle]="subtitle"></app-persona-view>
@if (hasFoundryStats) {
<section class="foundry-stats">
<h2>{{ 'enemyView.foundryStatsTitle' | translate }}</h2>
<p class="foundry-stats-hint">{{ 'enemyView.foundryStatsHint' | translate }}</p>
<table class="foundry-stats-table">
<tbody>
@for (s of foundryStatsEntries; track s.key) {
<tr>
<th>{{ s.key }}</th>
<td>{{ s.value }}</td>
</tr>
}
</tbody>
</table>
</section>
}
</div> </div>

View File

@@ -50,6 +50,49 @@
color: #d8b4fe; color: #d8b4fe;
} }
// Snapshot des stats importées de Foundry (lecture seule, figé).
.foundry-stats {
max-width: 1100px;
margin: 28px auto 0;
padding: 0 32px;
h2 {
font-size: 1rem;
color: #d1a878;
margin: 0 0 0.25rem;
}
.foundry-stats-hint {
margin: 0 0 0.75rem;
color: #9aa0aa;
font-size: 0.8rem;
font-style: italic;
}
.foundry-stats-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
th, td {
text-align: left;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: top;
}
th {
width: 45%;
color: #9aa0aa;
font-weight: 500;
font-family: monospace;
word-break: break-all;
}
td { color: #e0e0e0; }
}
}
// Pages de Lore liées au PNJ — chips cliquables sous la fiche. // Pages de Lore liées au PNJ — chips cliquables sous la fiche.
.nv-lore-links { .nv-lore-links {
max-width: 1100px; max-width: 1100px;

View File

@@ -77,6 +77,16 @@ export class EnemyViewComponent implements OnInit, OnDestroy {
this.paramsSub?.unsubscribe(); this.paramsSub?.unsubscribe();
} }
/** Stats importées de Foundry (snapshot figé), triées pour affichage. */
get foundryStatsEntries(): { key: string; value: string }[] {
const s = this.enemy?.foundryStats ?? {};
return Object.keys(s).sort().map(key => ({ key, value: s[key] }));
}
get hasFoundryStats(): boolean {
return this.foundryStatsEntries.length > 0;
}
/** Sous-titre de la fiche : niveau + dossier (« Niveau 8 · Démons »). */ /** Sous-titre de la fiche : niveau + dossier (« Niveau 8 · Démons »). */
get subtitle(): string { get subtitle(): string {
const parts: string[] = []; const parts: string[] = [];

View File

@@ -153,6 +153,22 @@
(fieldsChange)="npcTemplate = $event"> (fieldsChange)="npcTemplate = $event">
</app-template-fields-editor> </app-template-fields-editor>
@if (id) {
<div class="structure-import">
<button type="button" class="btn-secondary" [disabled]="importingStructure" (click)="structureInput.click()">
<lucide-icon [img]="Upload" [size]="14"></lucide-icon>
{{ (importingStructure ? 'gameSystemEdit.structureImporting' : 'gameSystemEdit.structureImport') | translate }}
</button>
<input #structureInput type="file" accept=".json,application/json" hidden (change)="onStructureSelected($event)" />
@if (foundryActorType) {
<span class="structure-actor-type">{{ 'gameSystemEdit.structureActorType' | translate:{ type: foundryActorType } }}</span>
}
@if (importStructureError) {
<span class="structure-error">{{ importStructureError }}</span>
}
</div>
}
<app-template-fields-editor <app-template-fields-editor
[label]="'gameSystemEdit.enemyFieldsLabel' | translate" [label]="'gameSystemEdit.enemyFieldsLabel' | translate"
[hint]="'gameSystemEdit.enemyFieldsHint' | translate" [hint]="'gameSystemEdit.enemyFieldsHint' | translate"

View File

@@ -88,6 +88,12 @@ export class GameSystemEditComponent implements OnInit {
npcTemplate: TemplateField[] = []; npcTemplate: TemplateField[] = [];
enemyTemplate: TemplateField[] = []; enemyTemplate: TemplateField[] = [];
/** Type d'acteur Foundry (posé par l'import de structure). */
foundryActorType: string | null = null;
/** Import de structure Foundry en cours. */
importingStructure = false;
importStructureError: string | null = null;
readonly suggestedSections = SUGGESTED_SECTIONS; readonly suggestedSections = SUGGESTED_SECTIONS;
readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS; readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS; readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
@@ -112,6 +118,7 @@ export class GameSystemEditComponent implements OnInit {
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : []; this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : []; this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
this.enemyTemplate = gs.enemyTemplate ? [...gs.enemyTemplate] : []; this.enemyTemplate = gs.enemyTemplate ? [...gs.enemyTemplate] : [];
this.foundryActorType = gs.foundryActorType ?? null;
}, },
error: () => this.back() error: () => this.back()
}); });
@@ -234,6 +241,47 @@ export class GameSystemEditComponent implements OnInit {
return count; return count;
} }
// --- Import d'une structure d'acteur Foundry (template ennemi) ------------
/** Déclenché par le <input file> caché : importe la structure choisie. */
onStructureSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) this.importStructureFile(file);
}
/**
* Importe une structure Foundry (.json du module) : REMPLACE le template ennemi
* par les champs mappés + pose le type d'acteur. Nécessite un système déjà
* enregistré (id). L'utilisateur élague/renomme ensuite, puis enregistre.
*/
private importStructureFile(file: File): void {
if (!this.id || this.importingStructure) return;
this.importStructureError = null;
file.text().then(txt => {
let structure: unknown;
try {
structure = JSON.parse(txt);
} catch {
this.importStructureError = this.translate.instant('gameSystemEdit.structureInvalid');
return;
}
this.importingStructure = true;
this.service.importFoundryStructure(this.id!, structure).subscribe({
next: (gs) => {
this.enemyTemplate = gs.enemyTemplate ? [...gs.enemyTemplate] : [];
this.foundryActorType = gs.foundryActorType ?? null;
this.importingStructure = false;
},
error: () => {
this.importingStructure = false;
this.importStructureError = this.translate.instant('gameSystemEdit.structureFailed');
}
});
});
}
removeSection(index: number): void { removeSection(index: number): void {
this.sections.splice(index, 1); this.sections.splice(index, 1);
} }
@@ -256,6 +304,7 @@ export class GameSystemEditComponent implements OnInit {
characterTemplate: this.characterTemplate, characterTemplate: this.characterTemplate,
npcTemplate: this.npcTemplate, npcTemplate: this.npcTemplate,
enemyTemplate: this.enemyTemplate, enemyTemplate: this.enemyTemplate,
foundryActorType: this.foundryActorType,
isPublic: false isPublic: false
}; };
const req = this.id const req = this.id

View File

@@ -19,6 +19,8 @@ export interface Enemy {
order?: number; order?: number;
/** UUID de l'acteur de compendium Foundry d'origine (monstre importé). Null sinon. */ /** UUID de l'acteur de compendium Foundry d'origine (monstre importé). Null sinon. */
foundryRef?: string | null; foundryRef?: string | null;
/** Instantané figé des stats Foundry (aplati clé→valeur), pour affichage en prep. */
foundryStats?: Record<string, string>;
} }
export interface EnemyCreate { export interface EnemyCreate {

View File

@@ -15,6 +15,8 @@ export interface GameSystem {
characterTemplate?: TemplateField[]; characterTemplate?: TemplateField[];
npcTemplate?: TemplateField[]; npcTemplate?: TemplateField[];
enemyTemplate?: TemplateField[]; enemyTemplate?: TemplateField[];
/** Type d'acteur Foundry pour l'export d'ennemis maison typés (import de structure). */
foundryActorType?: string | null;
author?: string | null; author?: string | null;
isPublic?: boolean; isPublic?: boolean;
} }
@@ -52,6 +54,7 @@ export interface GameSystemCreate {
characterTemplate?: TemplateField[]; characterTemplate?: TemplateField[];
npcTemplate?: TemplateField[]; npcTemplate?: TemplateField[];
enemyTemplate?: TemplateField[]; enemyTemplate?: TemplateField[];
foundryActorType?: string | null;
author?: string | null; author?: string | null;
isPublic: boolean; isPublic: boolean;
} }

View File

@@ -40,6 +40,14 @@ export class GameSystemService {
return this.http.get<GameSystem[]>(`${this.apiUrl}/search`, { params }); return this.http.get<GameSystem[]>(`${this.apiUrl}/search`, { params });
} }
/**
* 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.
*/
importFoundryStructure(id: string, structure: unknown): Observable<GameSystem> {
return this.http.post<GameSystem>(`${this.apiUrl}/${id}/import-foundry-structure`, structure);
}
/** /**
* Importe un PDF de règles : renvoie une PROPOSITION de sections (titre → * Importe un PDF de règles : renvoie une PROPOSITION de sections (titre →
* markdown). Rien n'est persisté côté serveur — l'appelant injecte les * markdown). Rien n'est persisté côté serveur — l'appelant injecte les

View File

@@ -35,6 +35,8 @@ export interface TemplateField {
* KEY_VALUE_LIST = libelles des lignes ; TABLE = noms des colonnes. * KEY_VALUE_LIST = libelles des lignes ; TABLE = noms des colonnes.
*/ */
labels?: string[] | null; labels?: string[] | null;
/** Chemin Foundry du champ (system.<path>) quand le template est calqué Foundry. */
foundryPath?: string | null;
} }
/** /**

View File

@@ -1,20 +1,25 @@
<div class="picker"> <div class="picker">
<!-- Chips des ennemis déjà liés --> <!-- Chips des ennemis déjà liés (avec quantité) -->
@if (linkedEnemies.length > 0) { @if (linkedGroups.length > 0) {
<div class="linked-chips"> <div class="linked-chips">
@for (e of linkedEnemies; track e.id) { @for (g of linkedGroups; track g.enemy.id) {
<span class="chip"> <span class="chip">
<a <a
class="chip-label" class="chip-label"
[href]="enemyUrl(e.id!)" [href]="enemyUrl(g.enemy.id!)"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
[title]="'enemyLinkPicker.openInNewTab' | translate"> [title]="'enemyLinkPicker.openInNewTab' | translate">
<lucide-icon [img]="Skull" [size]="12"></lucide-icon> <lucide-icon [img]="Skull" [size]="12"></lucide-icon>
{{ label(e) }} {{ label(g.enemy) }}
</a> </a>
<button type="button" class="chip-remove" (click)="remove(e.id!)" [title]="'enemyLinkPicker.removeLink' | translate"> <span class="chip-qty">
<button type="button" class="chip-step" (click)="decrement(g.enemy.id!)" [title]="'enemyLinkPicker.decrement' | translate"></button>
<span class="chip-count">×{{ g.count }}</span>
<button type="button" class="chip-step" (click)="increment(g.enemy.id!)" [title]="'enemyLinkPicker.increment' | translate">+</button>
</span>
<button type="button" class="chip-remove" (click)="remove(g.enemy.id!)" [title]="'enemyLinkPicker.removeLink' | translate">
<lucide-icon [img]="X" [size]="12"></lucide-icon> <lucide-icon [img]="X" [size]="12"></lucide-icon>
</button> </button>
</span> </span>

View File

@@ -33,6 +33,32 @@
&:hover { color: #fecdd3; } &:hover { color: #fecdd3; }
} }
.chip-qty {
display: inline-flex;
align-items: center;
border-left: 1px solid #2a2a3d;
.chip-step {
width: 1.4rem;
padding: 0.25rem 0;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
font-size: 0.95rem;
line-height: 1;
opacity: 0.8;
&:hover { opacity: 1; background: #2a2a3d; color: #fecdd3; }
}
.chip-count {
min-width: 1.6rem;
text-align: center;
font-variant-numeric: tabular-nums;
opacity: 0.9;
}
}
.chip-remove { .chip-remove {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -44,11 +44,20 @@ export class EnemyLinkPickerComponent {
/** true tant que l'input a le focus (pour afficher le dropdown). */ /** true tant que l'input a le focus (pour afficher le dropdown). */
dropdownOpen = false; dropdownOpen = false;
/** Ennemis actuellement liés (résolus en objets complets pour affichage). */ /**
get linkedEnemies(): Enemy[] { * Ennemis liés groupés AVEC leur quantité (le même id peut apparaître N fois
return this.value * dans `value` → N tokens à l'export). Ordre = première apparition.
.map(id => this.availableEnemies.find(e => e.id === id)) */
.filter((e): e is Enemy => !!e); get linkedGroups(): { enemy: Enemy; count: number }[] {
const counts = new Map<string, number>();
const order: string[] = [];
for (const id of this.value) {
if (!counts.has(id)) order.push(id);
counts.set(id, (counts.get(id) ?? 0) + 1);
}
return order
.map(id => ({ enemy: this.availableEnemies.find(e => e.id === id), count: counts.get(id)! }))
.filter((g): g is { enemy: Enemy; count: number } => !!g.enemy);
} }
/** Ennemis proposables dans le dropdown — filtrés par query, exclut les déjà liés. */ /** Ennemis proposables dans le dropdown — filtrés par query, exclut les déjà liés. */
@@ -66,14 +75,28 @@ export class EnemyLinkPickerComponent {
return level ? `${enemy.name} (${level})` : enemy.name; return level ? `${enemy.name} (${level})` : enemy.name;
} }
/** Ajoute un ennemi aux liens. */ /** Ajoute un ennemi aux liens (première occurrence). */
add(enemy: Enemy): void { add(enemy: Enemy): void {
if (!enemy.id || this.value.includes(enemy.id)) return; if (!enemy.id) return;
this.valueChange.emit([...this.value, enemy.id]); this.valueChange.emit([...this.value, enemy.id]);
this.query = ''; this.query = '';
} }
/** Retire un ennemi des liens. */ /** +1 du même ennemi (un token de plus à l'export). */
increment(enemyId: string): void {
this.valueChange.emit([...this.value, enemyId]);
}
/** 1 du même ennemi (retire UNE occurrence). */
decrement(enemyId: string): void {
const i = this.value.indexOf(enemyId);
if (i < 0) return;
const next = [...this.value];
next.splice(i, 1);
this.valueChange.emit(next);
}
/** Retire TOUTES les occurrences de cet ennemi. */
remove(enemyId: string): void { remove(enemyId: string): void {
this.valueChange.emit(this.value.filter(id => id !== enemyId)); this.valueChange.emit(this.value.filter(id => id !== enemyId));
} }

View File

@@ -368,6 +368,8 @@
"searchPlaceholder": "Search for an enemy from the bestiary...", "searchPlaceholder": "Search for an enemy from the bestiary...",
"openInNewTab": "Open the sheet in a new tab", "openInNewTab": "Open the sheet in a new tab",
"removeLink": "Remove link", "removeLink": "Remove link",
"increment": "One more",
"decrement": "One less",
"noMatch": "No matching enemy" "noMatch": "No matching enemy"
}, },
"personaView": { "personaView": {
@@ -1096,7 +1098,9 @@
"deleteMessage": "Delete \"{{name}}\"?" "deleteMessage": "Delete \"{{name}}\"?"
}, },
"enemyView": { "enemyView": {
"levelLong": "Level {{level}}" "levelLong": "Level {{level}}",
"foundryStatsTitle": "Foundry stats",
"foundryStatsHint": "Snapshot imported from Foundry (frozen — the live sheet stays in Foundry)."
}, },
"gameSystems": { "gameSystems": {
"heroTitle": "RPG Systems", "heroTitle": "RPG Systems",
@@ -1141,6 +1145,11 @@
"npcFieldsHint": "Shown when creating/editing a non-player character.", "npcFieldsHint": "Shown when creating/editing a non-player character.",
"enemyFieldsLabel": "Enemy sheet fields", "enemyFieldsLabel": "Enemy sheet fields",
"enemyFieldsHint": "Shown when creating/editing an enemy (bestiary). Name, level, folder, portrait and banner are automatic.", "enemyFieldsHint": "Shown when creating/editing an enemy (bestiary). Name, level, folder, portrait and banner are automatic.",
"structureImport": "Import a Foundry structure",
"structureImporting": "Importing…",
"structureActorType": "Foundry actor: {{type}}",
"structureInvalid": "Unreadable structure (invalid JSON).",
"structureFailed": "Structure import failed.",
"importExtracting": "Extracting text…", "importExtracting": "Extracting text…",
"importAnalyzing": "Analyzing rules… ({{current}}/{{total}})", "importAnalyzing": "Analyzing rules… ({{current}}/{{total}})",
"importFailed": "PDF import failed.", "importFailed": "PDF import failed.",

View File

@@ -368,6 +368,8 @@
"searchPlaceholder": "Rechercher un ennemi du bestiaire...", "searchPlaceholder": "Rechercher un ennemi du bestiaire...",
"openInNewTab": "Ouvrir la fiche dans un nouvel onglet", "openInNewTab": "Ouvrir la fiche dans un nouvel onglet",
"removeLink": "Retirer le lien", "removeLink": "Retirer le lien",
"increment": "Un de plus",
"decrement": "Un de moins",
"noMatch": "Aucun ennemi ne correspond" "noMatch": "Aucun ennemi ne correspond"
}, },
"personaView": { "personaView": {
@@ -1096,7 +1098,9 @@
"deleteMessage": "Supprimer « {{name}} » ?" "deleteMessage": "Supprimer « {{name}} » ?"
}, },
"enemyView": { "enemyView": {
"levelLong": "Niveau {{level}}" "levelLong": "Niveau {{level}}",
"foundryStatsTitle": "Statistiques Foundry",
"foundryStatsHint": "Instantané importé depuis Foundry (figé — la fiche vivante reste côté Foundry)."
}, },
"gameSystems": { "gameSystems": {
"heroTitle": "Systèmes de JDR", "heroTitle": "Systèmes de JDR",
@@ -1141,6 +1145,11 @@
"npcFieldsHint": "Affichés lors de la création/édition d'un personnage non-joueur.", "npcFieldsHint": "Affichés lors de la création/édition d'un personnage non-joueur.",
"enemyFieldsLabel": "Champs de la fiche Ennemi", "enemyFieldsLabel": "Champs de la fiche Ennemi",
"enemyFieldsHint": "Affichés lors de la création/édition d'un ennemi (bestiaire). Nom, niveau, dossier, portrait et bandeau sont automatiques.", "enemyFieldsHint": "Affichés lors de la création/édition d'un ennemi (bestiaire). Nom, niveau, dossier, portrait et bandeau sont automatiques.",
"structureImport": "Importer une structure Foundry",
"structureImporting": "Import…",
"structureActorType": "Acteur Foundry : {{type}}",
"structureInvalid": "Structure illisible (JSON invalide).",
"structureFailed": "Échec de l'import de la structure.",
"importExtracting": "Extraction du texte…", "importExtracting": "Extraction du texte…",
"importAnalyzing": "Analyse des règles… ({{current}}/{{total}})", "importAnalyzing": "Analyse des règles… ({{current}}/{{total}})",
"importFailed": "Échec de l'import du PDF.", "importFailed": "Échec de l'import du PDF.",

View File

@@ -92,6 +92,8 @@
"deleteMessage": "Delete \"{{name}}\"?" "deleteMessage": "Delete \"{{name}}\"?"
}, },
"enemyView": { "enemyView": {
"levelLong": "Level {{level}}" "levelLong": "Level {{level}}",
"foundryStatsTitle": "Foundry stats",
"foundryStatsHint": "Snapshot imported from Foundry (frozen — the live sheet stays in Foundry)."
} }
} }

View File

@@ -92,6 +92,8 @@
"deleteMessage": "Supprimer « {{name}} » ?" "deleteMessage": "Supprimer « {{name}} » ?"
}, },
"enemyView": { "enemyView": {
"levelLong": "Niveau {{level}}" "levelLong": "Niveau {{level}}",
"foundryStatsTitle": "Statistiques Foundry",
"foundryStatsHint": "Instantané importé depuis Foundry (figé — la fiche vivante reste côté Foundry)."
} }
} }

View File

@@ -42,6 +42,11 @@
"npcFieldsHint": "Shown when creating/editing a non-player character.", "npcFieldsHint": "Shown when creating/editing a non-player character.",
"enemyFieldsLabel": "Enemy sheet fields", "enemyFieldsLabel": "Enemy sheet fields",
"enemyFieldsHint": "Shown when creating/editing an enemy (bestiary). Name, level, folder, portrait and banner are automatic.", "enemyFieldsHint": "Shown when creating/editing an enemy (bestiary). Name, level, folder, portrait and banner are automatic.",
"structureImport": "Import a Foundry structure",
"structureImporting": "Importing…",
"structureActorType": "Foundry actor: {{type}}",
"structureInvalid": "Unreadable structure (invalid JSON).",
"structureFailed": "Structure import failed.",
"importExtracting": "Extracting text…", "importExtracting": "Extracting text…",
"importAnalyzing": "Analyzing rules… ({{current}}/{{total}})", "importAnalyzing": "Analyzing rules… ({{current}}/{{total}})",
"importFailed": "PDF import failed.", "importFailed": "PDF import failed.",

View File

@@ -42,6 +42,11 @@
"npcFieldsHint": "Affichés lors de la création/édition d'un personnage non-joueur.", "npcFieldsHint": "Affichés lors de la création/édition d'un personnage non-joueur.",
"enemyFieldsLabel": "Champs de la fiche Ennemi", "enemyFieldsLabel": "Champs de la fiche Ennemi",
"enemyFieldsHint": "Affichés lors de la création/édition d'un ennemi (bestiaire). Nom, niveau, dossier, portrait et bandeau sont automatiques.", "enemyFieldsHint": "Affichés lors de la création/édition d'un ennemi (bestiaire). Nom, niveau, dossier, portrait et bandeau sont automatiques.",
"structureImport": "Importer une structure Foundry",
"structureImporting": "Import…",
"structureActorType": "Acteur Foundry : {{type}}",
"structureInvalid": "Structure illisible (JSON invalide).",
"structureFailed": "Échec de l'import de la structure.",
"importExtracting": "Extraction du texte…", "importExtracting": "Extraction du texte…",
"importAnalyzing": "Analyse des règles… ({{current}}/{{total}})", "importAnalyzing": "Analyse des règles… ({{current}}/{{total}})",
"importFailed": "Échec de l'import du PDF.", "importFailed": "Échec de l'import du PDF.",

View File

@@ -18,6 +18,8 @@
"searchPlaceholder": "Search for an enemy from the bestiary...", "searchPlaceholder": "Search for an enemy from the bestiary...",
"openInNewTab": "Open the sheet in a new tab", "openInNewTab": "Open the sheet in a new tab",
"removeLink": "Remove link", "removeLink": "Remove link",
"increment": "One more",
"decrement": "One less",
"noMatch": "No matching enemy" "noMatch": "No matching enemy"
}, },
"personaView": { "personaView": {

View File

@@ -18,6 +18,8 @@
"searchPlaceholder": "Rechercher un ennemi du bestiaire...", "searchPlaceholder": "Rechercher un ennemi du bestiaire...",
"openInNewTab": "Ouvrir la fiche dans un nouvel onglet", "openInNewTab": "Ouvrir la fiche dans un nouvel onglet",
"removeLink": "Retirer le lien", "removeLink": "Retirer le lien",
"increment": "Un de plus",
"decrement": "Un de moins",
"noMatch": "Aucun ennemi ne correspond" "noMatch": "Aucun ennemi ne correspond"
}, },
"personaView": { "personaView": {