diff --git a/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java b/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java index 3a0b4aa..50a43dc 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java @@ -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 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 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<>()) diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java index 25f499a..33de436 100644 --- a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java +++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java @@ -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 characterTemplate, List npcTemplate, List 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 fields) { + GameSystem gs = gameSystemRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id)); + + List template = new ArrayList<>(); + Set usedNames = new HashSet<>(); + for (FoundryStructField f : fields == null ? List.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); } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java b/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java index 9d16a71..401a446 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java @@ -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 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 getFoundryStats() { + if (foundryStats == null) foundryStats = new HashMap<>(); + return foundryStats; + } } diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java index 3897f16..881d204 100644 --- a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java @@ -58,6 +58,13 @@ public class GameSystem { */ private List 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; diff --git a/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java b/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java index 6c26b60..000d61e 100644 --- a/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java +++ b/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java @@ -37,14 +37,26 @@ public class TemplateField { */ private List 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. = 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 labels) { + this(name, type, layout, labels, null); } /** Raccourci : construit un champ de type TEXT (cas le plus courant). */ diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java index 6acf78e..bb22bd0 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java @@ -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 foundryStats; + @Column(name = "\"order\"", nullable = false) private int order; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java index 09cf7c2..a2f185f 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java @@ -51,6 +51,10 @@ public class GameSystemJpaEntity { @Column(name = "enemy_template", columnDefinition = "TEXT") private List 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; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java index e9ffc8e..5a9db02 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java @@ -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()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java index cc6491d..d72b407 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java @@ -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()) diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java index 87cc3f8..fea16b7 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java @@ -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) { diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java index af68045..67bb060 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java @@ -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()); } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java index aee8ffc..7958088 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java @@ -58,6 +58,7 @@ public record ContentExport( List characterTemplate, List npcTemplate, List enemyTemplate, + String foundryActorType, String author, boolean isPublic ) {} @@ -210,6 +211,7 @@ public record ContentExport( Map> keyValueValues, Long campaignId, String foundryRef, + Map foundryStats, int order ) {} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java index eaef788..18dc22c 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java @@ -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 fields + String foundryRef, FoundryActor foundryActor, List 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 system) {} + /** Champ de fiche resolu : {type, label} + selon le type value | entries | assetIds. */ public record Field(String type, String label, String value, List entries, List assetIds) {} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java index fbb920f..061b707 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java @@ -101,6 +101,7 @@ public class FoundryExportService { List npcTemplate = resolveTemplate(campaign.getGameSystemId(), true); List 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. = 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 template, String actorType) { + if (actorType == null || actorType.isBlank() || template == null) return null; + if (e.getFoundryRef() != null && !e.getFoundryRef().isBlank()) return null; + Map system = new LinkedHashMap<>(); + Map 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 root, String path, Object value) { + String[] parts = path.split("\\."); + Map cur = root; + for (int i = 0; i < parts.length - 1; i++) { + Object next = cur.get(parts[i]); + if (!(next instanceof Map)) { + next = new LinkedHashMap(); + cur.put(parts[i], next); + } + cur = (Map) 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 fields(List template, Map values, Map> keyValueValues, diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryMonsterImportController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryMonsterImportController.java index 06ace26..d969fcf 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryMonsterImportController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryMonsterImportController.java @@ -26,7 +26,8 @@ public class FoundryMonsterImportController { /** Format du catalogue produit par le module Foundry. */ public record MonsterCatalog(String system, List monsters) {} - public record MonsterEntry(String name, String uuid, String img) {} + public record MonsterEntry(String name, String uuid, String type, String img, + java.util.Map stats, String folder) {} @PostMapping("/import-foundry-monsters") public ResponseEntity importMonsters( @@ -34,7 +35,7 @@ public class FoundryMonsterImportController { @RequestBody MonsterCatalog catalog) { List monsters = (catalog.monsters() == null ? List.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)); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java index 855468d..787faea 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java @@ -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 importFoundryStructure( + @PathVariable String id, @RequestBody FoundryStructureRequest req) { + List fields = + (req.fields() == null ? List.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 fields) {} + + public record StructFieldDto(String path, String label, String type) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java index d1acc32..2526c66 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java @@ -20,6 +20,8 @@ public class GameSystemDTO { private List characterTemplate = new ArrayList<>(); private List npcTemplate = new ArrayList<>(); private List 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; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java index 2b1f5c6..f8a3b5b 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java @@ -26,13 +26,21 @@ public class TemplateFieldDTO { /** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */ private List 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 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); } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java index c611dc0..cc06759 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java @@ -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(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java index 0fe11e2..e017d8a 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java @@ -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). */ diff --git a/core/src/main/resources/db/migration/V4__enemy_foundry_stats.sql b/core/src/main/resources/db/migration/V4__enemy_foundry_stats.sql new file mode 100644 index 0000000..0890e1e --- /dev/null +++ b/core/src/main/resources/db/migration/V4__enemy_foundry_stats.sql @@ -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; diff --git a/core/src/main/resources/db/migration/V5__gamesystem_foundry_actor_type.sql b/core/src/main/resources/db/migration/V5__gamesystem_foundry_actor_type.sql new file mode 100644 index 0000000..34f1d14 --- /dev/null +++ b/core/src/main/resources/db/migration/V5__gamesystem_foundry_actor_type.sql @@ -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. = valeur. Nullable. + +alter table game_systems add column foundry_actor_type varchar(64); diff --git a/core/src/test/java/com/loremind/application/campaigncontext/EnemyServiceMonsterImportTest.java b/core/src/test/java/com/loremind/application/campaigncontext/EnemyServiceMonsterImportTest.java index 9b27c24..efe0de3 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/EnemyServiceMonsterImportTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/EnemyServiceMonsterImportTest.java @@ -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()); } diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/GameSystemStructureExportTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/GameSystemStructureExportTest.java new file mode 100644 index 0000000..b6e97c5 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/GameSystemStructureExportTest.java @@ -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. = 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 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 root, String... path) { + Object cur = root; + for (String p : path) cur = ((Map) cur).get(p); + return cur; + } +} diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html index fcc8fec..eba2a76 100644 --- a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html @@ -28,7 +28,7 @@ @if (g.folder) {

- {{ g.folder }} + {{ g.folder.split('/').join(' / ') }} {{ g.enemies.length }}

} @else if (groups.length > 1) { diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html index 9ac6474..3ffb6dc 100644 --- a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html @@ -12,4 +12,21 @@ + + @if (hasFoundryStats) { +
+

{{ 'enemyView.foundryStatsTitle' | translate }}

+

{{ 'enemyView.foundryStatsHint' | translate }}

+ + + @for (s of foundryStatsEntries; track s.key) { + + + + + } + +
{{ s.key }}{{ s.value }}
+
+ } diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss index 406bcad..156cc88 100644 --- a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss @@ -50,6 +50,49 @@ 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. .nv-lore-links { max-width: 1100px; diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts index 1c5b5b1..26015d4 100644 --- a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts @@ -77,6 +77,16 @@ export class EnemyViewComponent implements OnInit, OnDestroy { 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 »). */ get subtitle(): string { const parts: string[] = []; diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html index 3f6cef7..b2f3dea 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html @@ -153,6 +153,22 @@ (fieldsChange)="npcTemplate = $event"> + @if (id) { +
+ + + @if (foundryActorType) { + {{ 'gameSystemEdit.structureActorType' | translate:{ type: foundryActorType } }} + } + @if (importStructureError) { + {{ importStructureError }} + } +
+ } + this.back() }); @@ -234,6 +241,47 @@ export class GameSystemEditComponent implements OnInit { return count; } + // --- Import d'une structure d'acteur Foundry (template ennemi) ------------ + + /** Déclenché par le 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 { this.sections.splice(index, 1); } @@ -256,6 +304,7 @@ export class GameSystemEditComponent implements OnInit { characterTemplate: this.characterTemplate, npcTemplate: this.npcTemplate, enemyTemplate: this.enemyTemplate, + foundryActorType: this.foundryActorType, isPublic: false }; const req = this.id diff --git a/web/src/app/services/enemy.model.ts b/web/src/app/services/enemy.model.ts index 51d62cb..29be578 100644 --- a/web/src/app/services/enemy.model.ts +++ b/web/src/app/services/enemy.model.ts @@ -19,6 +19,8 @@ export interface Enemy { order?: number; /** UUID de l'acteur de compendium Foundry d'origine (monstre importé). Null sinon. */ foundryRef?: string | null; + /** Instantané figé des stats Foundry (aplati clé→valeur), pour affichage en prep. */ + foundryStats?: Record; } export interface EnemyCreate { diff --git a/web/src/app/services/game-system.model.ts b/web/src/app/services/game-system.model.ts index 483fdde..2eb1f1f 100644 --- a/web/src/app/services/game-system.model.ts +++ b/web/src/app/services/game-system.model.ts @@ -15,6 +15,8 @@ export interface GameSystem { characterTemplate?: TemplateField[]; npcTemplate?: TemplateField[]; enemyTemplate?: TemplateField[]; + /** Type d'acteur Foundry pour l'export d'ennemis maison typés (import de structure). */ + foundryActorType?: string | null; author?: string | null; isPublic?: boolean; } @@ -52,6 +54,7 @@ export interface GameSystemCreate { characterTemplate?: TemplateField[]; npcTemplate?: TemplateField[]; enemyTemplate?: TemplateField[]; + foundryActorType?: string | null; author?: string | null; isPublic: boolean; } diff --git a/web/src/app/services/game-system.service.ts b/web/src/app/services/game-system.service.ts index b05a481..62768f0 100644 --- a/web/src/app/services/game-system.service.ts +++ b/web/src/app/services/game-system.service.ts @@ -40,6 +40,14 @@ export class GameSystemService { return this.http.get(`${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 { + return this.http.post(`${this.apiUrl}/${id}/import-foundry-structure`, structure); + } + /** * Importe un PDF de règles : renvoie une PROPOSITION de sections (titre → * markdown). Rien n'est persisté côté serveur — l'appelant injecte les diff --git a/web/src/app/services/template.model.ts b/web/src/app/services/template.model.ts index c782df5..353615f 100644 --- a/web/src/app/services/template.model.ts +++ b/web/src/app/services/template.model.ts @@ -35,6 +35,8 @@ export interface TemplateField { * KEY_VALUE_LIST = libelles des lignes ; TABLE = noms des colonnes. */ labels?: string[] | null; + /** Chemin Foundry du champ (system.) quand le template est calqué Foundry. */ + foundryPath?: string | null; } /** diff --git a/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.html b/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.html index a051319..cd0f790 100644 --- a/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.html +++ b/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.html @@ -1,20 +1,25 @@
- - @if (linkedEnemies.length > 0) { + + @if (linkedGroups.length > 0) {
- @for (e of linkedEnemies; track e.id) { + @for (g of linkedGroups; track g.enemy.id) { - {{ label(e) }} + {{ label(g.enemy) }} - + ×{{ g.count }} + + + diff --git a/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.scss b/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.scss index cc14640..2796392 100644 --- a/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.scss +++ b/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.scss @@ -33,6 +33,32 @@ &: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 { display: inline-flex; align-items: center; diff --git a/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.ts b/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.ts index e3dc67b..1bc7bbc 100644 --- a/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.ts +++ b/web/src/app/shared/enemy-link-picker/enemy-link-picker.component.ts @@ -44,11 +44,20 @@ export class EnemyLinkPickerComponent { /** true tant que l'input a le focus (pour afficher le dropdown). */ dropdownOpen = false; - /** Ennemis actuellement liés (résolus en objets complets pour affichage). */ - get linkedEnemies(): Enemy[] { - return this.value - .map(id => this.availableEnemies.find(e => e.id === id)) - .filter((e): e is Enemy => !!e); + /** + * Ennemis liés groupés AVEC leur quantité (le même id peut apparaître N fois + * dans `value` → N tokens à l'export). Ordre = première apparition. + */ + get linkedGroups(): { enemy: Enemy; count: number }[] { + const counts = new Map(); + 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. */ @@ -66,14 +75,28 @@ export class EnemyLinkPickerComponent { return level ? `${enemy.name} (${level})` : enemy.name; } - /** Ajoute un ennemi aux liens. */ + /** Ajoute un ennemi aux liens (première occurrence). */ 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.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 { this.valueChange.emit(this.value.filter(id => id !== enemyId)); } diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index bcd262c..f09d8d5 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -368,6 +368,8 @@ "searchPlaceholder": "Search for an enemy from the bestiary...", "openInNewTab": "Open the sheet in a new tab", "removeLink": "Remove link", + "increment": "One more", + "decrement": "One less", "noMatch": "No matching enemy" }, "personaView": { @@ -1096,7 +1098,9 @@ "deleteMessage": "Delete \"{{name}}\"?" }, "enemyView": { - "levelLong": "Level {{level}}" + "levelLong": "Level {{level}}", + "foundryStatsTitle": "Foundry stats", + "foundryStatsHint": "Snapshot imported from Foundry (frozen — the live sheet stays in Foundry)." }, "gameSystems": { "heroTitle": "RPG Systems", @@ -1141,6 +1145,11 @@ "npcFieldsHint": "Shown when creating/editing a non-player character.", "enemyFieldsLabel": "Enemy sheet fields", "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…", "importAnalyzing": "Analyzing rules… ({{current}}/{{total}})", "importFailed": "PDF import failed.", diff --git a/web/src/assets/i18n/fr.json b/web/src/assets/i18n/fr.json index acd023b..6378fb5 100644 --- a/web/src/assets/i18n/fr.json +++ b/web/src/assets/i18n/fr.json @@ -368,6 +368,8 @@ "searchPlaceholder": "Rechercher un ennemi du bestiaire...", "openInNewTab": "Ouvrir la fiche dans un nouvel onglet", "removeLink": "Retirer le lien", + "increment": "Un de plus", + "decrement": "Un de moins", "noMatch": "Aucun ennemi ne correspond" }, "personaView": { @@ -1096,7 +1098,9 @@ "deleteMessage": "Supprimer « {{name}} » ?" }, "enemyView": { - "levelLong": "Niveau {{level}}" + "levelLong": "Niveau {{level}}", + "foundryStatsTitle": "Statistiques Foundry", + "foundryStatsHint": "Instantané importé depuis Foundry (figé — la fiche vivante reste côté Foundry)." }, "gameSystems": { "heroTitle": "Systèmes de JDR", @@ -1141,6 +1145,11 @@ "npcFieldsHint": "Affichés lors de la création/édition d'un personnage non-joueur.", "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.", + "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…", "importAnalyzing": "Analyse des règles… ({{current}}/{{total}})", "importFailed": "Échec de l'import du PDF.", diff --git a/web/src/assets/i18n/fragments/chars-npcs-enemies.en.json b/web/src/assets/i18n/fragments/chars-npcs-enemies.en.json index 31c1885..2d144a9 100644 --- a/web/src/assets/i18n/fragments/chars-npcs-enemies.en.json +++ b/web/src/assets/i18n/fragments/chars-npcs-enemies.en.json @@ -92,6 +92,8 @@ "deleteMessage": "Delete \"{{name}}\"?" }, "enemyView": { - "levelLong": "Level {{level}}" + "levelLong": "Level {{level}}", + "foundryStatsTitle": "Foundry stats", + "foundryStatsHint": "Snapshot imported from Foundry (frozen — the live sheet stays in Foundry)." } } diff --git a/web/src/assets/i18n/fragments/chars-npcs-enemies.fr.json b/web/src/assets/i18n/fragments/chars-npcs-enemies.fr.json index f8eba24..10079bd 100644 --- a/web/src/assets/i18n/fragments/chars-npcs-enemies.fr.json +++ b/web/src/assets/i18n/fragments/chars-npcs-enemies.fr.json @@ -92,6 +92,8 @@ "deleteMessage": "Supprimer « {{name}} » ?" }, "enemyView": { - "levelLong": "Niveau {{level}}" + "levelLong": "Niveau {{level}}", + "foundryStatsTitle": "Statistiques Foundry", + "foundryStatsHint": "Instantané importé depuis Foundry (figé — la fiche vivante reste côté Foundry)." } } diff --git a/web/src/assets/i18n/fragments/gamesys-settingssub.en.json b/web/src/assets/i18n/fragments/gamesys-settingssub.en.json index 8b343a0..0895695 100644 --- a/web/src/assets/i18n/fragments/gamesys-settingssub.en.json +++ b/web/src/assets/i18n/fragments/gamesys-settingssub.en.json @@ -42,6 +42,11 @@ "npcFieldsHint": "Shown when creating/editing a non-player character.", "enemyFieldsLabel": "Enemy sheet fields", "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…", "importAnalyzing": "Analyzing rules… ({{current}}/{{total}})", "importFailed": "PDF import failed.", diff --git a/web/src/assets/i18n/fragments/gamesys-settingssub.fr.json b/web/src/assets/i18n/fragments/gamesys-settingssub.fr.json index d82b370..8b5aba6 100644 --- a/web/src/assets/i18n/fragments/gamesys-settingssub.fr.json +++ b/web/src/assets/i18n/fragments/gamesys-settingssub.fr.json @@ -42,6 +42,11 @@ "npcFieldsHint": "Affichés lors de la création/édition d'un personnage non-joueur.", "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.", + "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…", "importAnalyzing": "Analyse des règles… ({{current}}/{{total}})", "importFailed": "Échec de l'import du PDF.", diff --git a/web/src/assets/i18n/fragments/shared-widgets-a.en.json b/web/src/assets/i18n/fragments/shared-widgets-a.en.json index 2602ff7..0d3d30b 100644 --- a/web/src/assets/i18n/fragments/shared-widgets-a.en.json +++ b/web/src/assets/i18n/fragments/shared-widgets-a.en.json @@ -18,6 +18,8 @@ "searchPlaceholder": "Search for an enemy from the bestiary...", "openInNewTab": "Open the sheet in a new tab", "removeLink": "Remove link", + "increment": "One more", + "decrement": "One less", "noMatch": "No matching enemy" }, "personaView": { diff --git a/web/src/assets/i18n/fragments/shared-widgets-a.fr.json b/web/src/assets/i18n/fragments/shared-widgets-a.fr.json index 366bd3a..d618eef 100644 --- a/web/src/assets/i18n/fragments/shared-widgets-a.fr.json +++ b/web/src/assets/i18n/fragments/shared-widgets-a.fr.json @@ -18,6 +18,8 @@ "searchPlaceholder": "Rechercher un ennemi du bestiaire...", "openInNewTab": "Ouvrir la fiche dans un nouvel onglet", "removeLink": "Retirer le lien", + "increment": "Un de plus", + "decrement": "Un de moins", "noMatch": "Aucun ennemi ne correspond" }, "personaView": {