Some checks failed
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m9s
Build & Push Images / build (brain) (push) Successful in 1m10s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m36s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (core) (push) Successful in 3m9s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 38s
CI (Gitea Actions) : - Nouveau workflow quality.yml (séparé de ci.yml, non bloquant pour la release) : MegaLinter v9 (PMD, Ruff, Bandit, gitleaks, hadolint), ng lint (web) et Trivy (CVE des dépendances Maven/pip/npm), sur main + beta + PR - Trivy : préchargement du cache Maven avant le scan — sans ça Maven Central rate-limite l'IP du runner (429) en résolvant les BOMs du parent Spring Boot - upload-artifact v4 → v3 : l'API artifacts v4 n'est pas supportée par Gitea (corrige aussi l'upload du rapport Playwright de e2e.yml, cassé depuis toujours) - Ruleset PMD projet (java-pmd-ruleset.xml, auto-détecté par MegaLinter) orienté bugs réels, sans le style Lombok-hostile du défaut : 11 337 → 65 findings ; PMD en mode rapport (JAVA_PMD_DISABLE_ERRORS) le temps de purger ce backlog Web : - angular-eslint 21 + eslint-plugin-sonarjs : les règles Sonar vivent désormais dans ESLint (config web/eslint.config.js, réglages justifiés en commentaire) - tsconfig : skipLibCheck + types:[] + node_modules ré-exclu — répare les builds desktop Windows/Linux cassés par le conflit @types/eslint-scope vs ESLint 9 - Purge du backlog lint : 114 erreurs → 0 sur 66 fichiers (ngOnDestroy vides supprimés, any typés avec les vrais DTOs, outputs (close) → (closed), regex super-linéaires désamorcées, ternaires/fonctions imbriqués dépliés, intention documentée sur les catch/error volontairement vides) - Bug latent corrigé au passage : license.service.disconnect() émettait undefined au lieu de true en cas de succès (masqué par un double cast)
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
import { Component, OnInit } from '@angular/core';
|
|
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight, Upload } from 'lucide-angular';
|
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
|
import { GameSystemService } from '../../services/game-system.service';
|
|
import { TemplateField } from '../../services/template.model';
|
|
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
|
|
|
|
/**
|
|
* Éditeur plein écran d'un GameSystem. Rôle double création/édition :
|
|
* - `/game-systems/create` → POST au submit
|
|
* - `/game-systems/:id/edit` → PUT au submit
|
|
*
|
|
* UI par sections : au lieu d'un gros textarea de markdown, on présente
|
|
* une liste de cartes repliables (une par section H2). Au load on parse
|
|
* le markdown existant, au submit on le reconstruit. Les noms suggérés
|
|
* (Combat, Classes…) guident sans imposer — c'est le même mapping que
|
|
* le backend utilise pour filtrer les sections à injecter dans l'IA.
|
|
*/
|
|
interface RuleSection {
|
|
title: string;
|
|
content: string;
|
|
collapsed: boolean;
|
|
}
|
|
|
|
/**
|
|
* Sections suggérées : mécaniques de jeu uniquement. Lore/factions/PNJ
|
|
* appartiennent au module Lore de LoreMind (décrit l'univers de l'utilisateur),
|
|
* pas au GameSystem (décrit les règles). Séparation volontaire.
|
|
*/
|
|
const SUGGESTED_SECTIONS = [
|
|
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
|
|
];
|
|
|
|
/** Suggestions de champs pour la fiche PJ — generiques (extension par template). */
|
|
const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'Notes'];
|
|
|
|
/** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */
|
|
const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ'];
|
|
|
|
/** Suggestions de champs pour la fiche ENNEMI — stats de combat. */
|
|
const ENEMY_FIELD_SUGGESTIONS = ['Stats', 'Attaques', 'Capacités', 'Faiblesses', 'Butin', 'Tactique'];
|
|
|
|
@Component({
|
|
selector: 'app-game-system-edit',
|
|
imports: [FormsModule, LucideAngularModule, TranslatePipe, TemplateFieldsEditorComponent],
|
|
templateUrl: './game-system-edit.component.html',
|
|
styleUrls: ['./game-system-edit.component.scss']
|
|
})
|
|
export class GameSystemEditComponent implements OnInit {
|
|
readonly Save = Save;
|
|
readonly ArrowLeft = ArrowLeft;
|
|
readonly Dices = Dices;
|
|
readonly Plus = Plus;
|
|
readonly Trash2 = Trash2;
|
|
readonly ChevronDown = ChevronDown;
|
|
readonly ChevronRight = ChevronRight;
|
|
readonly Upload = Upload;
|
|
|
|
id: string | null = null;
|
|
|
|
/** Import PDF en cours (appel LLM long) → désactive le bouton + spinner. */
|
|
importing = false;
|
|
/** Message de succès post-import (sections ajoutées, pages, OCR). */
|
|
importNote: string | null = null;
|
|
/** Message d'erreur d'import (Brain injoignable, PDF illisible…). */
|
|
importError: string | null = null;
|
|
/** Libellé de l'étape courante (« Extraction… », « Analyse… (3/12) »). */
|
|
importPhase = '';
|
|
/** Avancement de la structuration ; null pendant l'extraction (total inconnu). */
|
|
importProgress: { current: number; total: number } | null = null;
|
|
/** Titres de sections trouvés au fil de l'eau (affichage live). */
|
|
importFound: string[] = [];
|
|
/**
|
|
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
|
|
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
|
|
* en cours, pas l'historique.
|
|
*/
|
|
importStatus: string | null = null;
|
|
|
|
name = '';
|
|
description = '';
|
|
author = '';
|
|
sections: RuleSection[] = [];
|
|
characterTemplate: TemplateField[] = [];
|
|
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;
|
|
readonly enemyFieldSuggestions = ENEMY_FIELD_SUGGESTIONS;
|
|
|
|
constructor(
|
|
private route: ActivatedRoute,
|
|
private router: Router,
|
|
private service: GameSystemService,
|
|
private translate: TranslateService
|
|
) {}
|
|
|
|
ngOnInit(): void {
|
|
this.id = this.route.snapshot.paramMap.get('id');
|
|
if (this.id) {
|
|
this.service.getById(this.id).subscribe({
|
|
next: (gs) => {
|
|
this.name = gs.name;
|
|
this.description = gs.description ?? '';
|
|
this.author = gs.author ?? '';
|
|
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
|
|
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()
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Noms de section déjà utilisés — pour désactiver les suggestions en doublon. */
|
|
isSectionUsed(title: string): boolean {
|
|
const lower = title.toLowerCase();
|
|
return this.sections.some(s => s.title.toLowerCase() === lower);
|
|
}
|
|
|
|
/** Ajoute une section avec un nom suggéré. */
|
|
addSuggested(title: string): void {
|
|
if (this.isSectionUsed(title)) return;
|
|
this.sections.push({ title, content: '', collapsed: false });
|
|
}
|
|
|
|
/** Ajoute une section vierge (titre à remplir). */
|
|
addBlank(): void {
|
|
this.sections.push({ title: '', content: '', collapsed: false });
|
|
}
|
|
|
|
// --- Import d'un PDF de règles -------------------------------------------
|
|
|
|
/** Déclenché par le <input file> caché : lance l'import du PDF choisi. */
|
|
onPdfSelected(event: Event): void {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
// Reset de la valeur : re-sélectionner le même fichier doit re-déclencher.
|
|
input.value = '';
|
|
if (file) this.importPdf(file);
|
|
}
|
|
|
|
private importPdf(file: File): void {
|
|
this.importing = true;
|
|
this.importNote = null;
|
|
this.importError = null;
|
|
this.importPhase = this.translate.instant('gameSystemEdit.importExtracting');
|
|
this.importProgress = null;
|
|
this.importFound = [];
|
|
this.importStatus = null;
|
|
|
|
this.service.importRulesStream(file).subscribe({
|
|
next: (ev) => {
|
|
if (ev.type === 'progress') {
|
|
// Un morceau vient d'aboutir : le message d'attente est obsolète.
|
|
this.importStatus = null;
|
|
if (ev.total === 0) {
|
|
// Phase d'extraction (total encore inconnu).
|
|
this.importPhase = this.translate.instant('gameSystemEdit.importExtracting');
|
|
this.importProgress = null;
|
|
} else {
|
|
this.importPhase = this.translate.instant('gameSystemEdit.importAnalyzing', { current: ev.current, total: ev.total });
|
|
this.importProgress = { current: ev.current, total: ev.total };
|
|
for (const t of ev.newSectionTitles) {
|
|
if (!this.importFound.includes(t)) this.importFound.push(t);
|
|
}
|
|
}
|
|
} else if (ev.type === 'status') {
|
|
this.importStatus = ev.message;
|
|
} else if (ev.type === 'done') {
|
|
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
|
|
}
|
|
},
|
|
error: (err: Error) => {
|
|
this.resetImportProgress();
|
|
this.importError = err?.message
|
|
? this.translate.instant('gameSystemEdit.importFailedReason', { reason: err.message })
|
|
: this.translate.instant('gameSystemEdit.importFailed');
|
|
}
|
|
});
|
|
}
|
|
|
|
private finishImport(sections: Record<string, string>, pageCount: number, ocrPageCount: number): void {
|
|
this.resetImportProgress();
|
|
const added = this.mergeImportedSections(sections);
|
|
if (added === 0) {
|
|
this.importError = this.translate.instant('gameSystemEdit.importNoRules');
|
|
return;
|
|
}
|
|
const ocr = ocrPageCount > 0
|
|
? this.translate.instant('gameSystemEdit.importOcrSuffix', { count: ocrPageCount })
|
|
: '';
|
|
this.importNote = this.translate.instant('gameSystemEdit.importNote', {
|
|
added,
|
|
pages: pageCount,
|
|
ocr
|
|
});
|
|
}
|
|
|
|
private resetImportProgress(): void {
|
|
this.importing = false;
|
|
this.importPhase = '';
|
|
this.importProgress = null;
|
|
}
|
|
|
|
/**
|
|
* Fusionne les sections proposées dans l'éditeur. Section de même titre
|
|
* (insensible à la casse) → contenu ajouté à la suite ; sinon nouvelle carte.
|
|
* Retourne le nombre de sections effectivement intégrées.
|
|
*/
|
|
private mergeImportedSections(sections: Record<string, string>): number {
|
|
let count = 0;
|
|
for (const [rawTitle, rawContent] of Object.entries(sections ?? {})) {
|
|
const title = (rawTitle ?? '').trim();
|
|
const content = (rawContent ?? '').trim();
|
|
if (!title || !content) continue;
|
|
const existing = this.sections.find(
|
|
s => s.title.trim().toLowerCase() === title.toLowerCase()
|
|
);
|
|
if (existing) {
|
|
existing.content = `${existing.content.trim()}\n\n${content}`.trim();
|
|
existing.collapsed = false;
|
|
} else {
|
|
this.sections.push({ title, content, collapsed: false });
|
|
}
|
|
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 {
|
|
this.sections.splice(index, 1);
|
|
}
|
|
|
|
toggleCollapse(section: RuleSection): void {
|
|
section.collapsed = !section.collapsed;
|
|
}
|
|
|
|
submit(): void {
|
|
if (!this.name.trim()) return;
|
|
if (this.hasInvalidTemplateFields()) {
|
|
console.warn('Champs templates invalides (noms vides ou doublons) — sauvegarde bloquee.');
|
|
return;
|
|
}
|
|
const payload = {
|
|
name: this.name.trim(),
|
|
description: this.description.trim() || null,
|
|
author: this.author.trim() || null,
|
|
rulesMarkdown: this.serializeMarkdown(),
|
|
characterTemplate: this.characterTemplate,
|
|
npcTemplate: this.npcTemplate,
|
|
enemyTemplate: this.enemyTemplate,
|
|
foundryActorType: this.foundryActorType,
|
|
isPublic: false
|
|
};
|
|
const req = this.id
|
|
? this.service.update(this.id, payload)
|
|
: this.service.create(payload);
|
|
req.subscribe({
|
|
next: () => this.back(),
|
|
error: () => console.error('Erreur sauvegarde GameSystem')
|
|
});
|
|
}
|
|
|
|
back(): void {
|
|
this.router.navigate(['/game-systems']);
|
|
}
|
|
|
|
/** Validation cote front : nom vide ou doublons (case-insensitive). */
|
|
private hasInvalidTemplateFields(): boolean {
|
|
return this.hasInvalidList(this.characterTemplate)
|
|
|| this.hasInvalidList(this.npcTemplate)
|
|
|| this.hasInvalidList(this.enemyTemplate);
|
|
}
|
|
|
|
private hasInvalidList(fields: TemplateField[]): boolean {
|
|
const seen = new Set<string>();
|
|
for (const f of fields) {
|
|
const name = f.name?.trim().toLowerCase();
|
|
if (!name) return true;
|
|
if (seen.has(name)) return true;
|
|
seen.add(name);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- Parse / Serialize markdown ------------------------------------------
|
|
|
|
/**
|
|
* Découpe un markdown par titres H2 en sections. Doit rester cohérent avec
|
|
* le parser Java côté backend (regex `^##\s+…$`). Le texte avant le premier
|
|
* H2 est volontairement perdu — le backend l'ignore aussi pour l'injection IA.
|
|
*/
|
|
private parseMarkdown(markdown: string): RuleSection[] {
|
|
if (!markdown) return [];
|
|
const sections: RuleSection[] = [];
|
|
const lines = markdown.split('\n');
|
|
let current: RuleSection | null = null;
|
|
const buffer: string[] = [];
|
|
|
|
const flush = () => {
|
|
if (current) {
|
|
current.content = buffer.join('\n').trim();
|
|
sections.push(current);
|
|
}
|
|
buffer.length = 0;
|
|
};
|
|
|
|
for (const line of lines) {
|
|
// `\S` force un début de titre non-blanc : frontière déterministe entre
|
|
// `\s+` et la capture (l'ancien `.+?`+`\s*$` backtrackait). Le trim()
|
|
// en aval retire les espaces de fin.
|
|
const match = line.match(/^##\s+(\S.*)$/);
|
|
if (match) {
|
|
flush();
|
|
current = { title: match[1].trim(), content: '', collapsed: false };
|
|
} else if (current) {
|
|
buffer.push(line);
|
|
}
|
|
}
|
|
flush();
|
|
return sections;
|
|
}
|
|
|
|
/** Reconstruit le markdown à partir des sections (ignore les sections vides). */
|
|
private serializeMarkdown(): string | null {
|
|
const valid = this.sections.filter(s => s.title.trim() || s.content.trim());
|
|
if (valid.length === 0) return null;
|
|
return valid
|
|
.map(s => {
|
|
// Le "## " est RESERVE aux titres de section (parseMarkdown decoupe dessus,
|
|
// tout comme le parser Java cote backend). Or le contenu genere par l'IA
|
|
// contient souvent des sous-titres "## " : sans rien faire, ils seraient
|
|
// re-interpretes comme de nouvelles sections au rechargement -> explosion
|
|
// de sections + sections vides. On les retrograde donc en "### " (un niveau
|
|
// plus bas) pour garder un round-trip stable. (### / #### ne collisionnent pas.)
|
|
const content = s.content.trim().replace(/^##[ \t]+/gm, '### ');
|
|
return `## ${s.title.trim() || 'Sans titre'}\n${content}`;
|
|
})
|
|
.join('\n\n');
|
|
}
|
|
}
|