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

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

View File

@@ -12,4 +12,21 @@
</div>
<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>

View File

@@ -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;

View File

@@ -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[] = [];

View File

@@ -153,6 +153,22 @@
(fieldsChange)="npcTemplate = $event">
</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
[label]="'gameSystemEdit.enemyFieldsLabel' | translate"
[hint]="'gameSystemEdit.enemyFieldsHint' | translate"

View File

@@ -88,6 +88,12 @@ export class GameSystemEditComponent implements OnInit {
npcTemplate: 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 characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
@@ -112,6 +118,7 @@ export class GameSystemEditComponent implements OnInit {
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
this.enemyTemplate = gs.enemyTemplate ? [...gs.enemyTemplate] : [];
this.foundryActorType = gs.foundryActorType ?? null;
},
error: () => 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 <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 {
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

View File

@@ -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<string, string>;
}
export interface EnemyCreate {

View File

@@ -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;
}

View File

@@ -40,6 +40,14 @@ export class GameSystemService {
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 →
* 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.
*/
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">
<!-- Chips des ennemis déjà liés -->
@if (linkedEnemies.length > 0) {
<!-- Chips des ennemis déjà liés (avec quantité) -->
@if (linkedGroups.length > 0) {
<div class="linked-chips">
@for (e of linkedEnemies; track e.id) {
@for (g of linkedGroups; track g.enemy.id) {
<span class="chip">
<a
class="chip-label"
[href]="enemyUrl(e.id!)"
[href]="enemyUrl(g.enemy.id!)"
target="_blank"
rel="noopener"
[title]="'enemyLinkPicker.openInNewTab' | translate">
<lucide-icon [img]="Skull" [size]="12"></lucide-icon>
{{ label(e) }}
{{ label(g.enemy) }}
</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>
</button>
</span>

View File

@@ -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;

View File

@@ -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<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. */
@@ -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));
}