Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
This commit is contained in:
2026-06-03 15:44:48 +02:00
parent e3fc96a1bc
commit 504c4b7b6c
147 changed files with 5347 additions and 392 deletions

View File

@@ -16,6 +16,8 @@ export const routes: Routes = [
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) },
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },

View File

@@ -17,6 +17,22 @@
/>
</div>
<div class="field">
<label>Structure de l'arc</label>
<div class="arc-type-choice">
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
<input type="radio" formControlName="type" value="LINEAR" />
<span class="arc-type-title">Linéaire</span>
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle classique.</span>
</label>
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
<input type="radio" formControlName="type" value="HUB" />
<span class="arc-type-title">Hub</span>
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions — type sandbox (ex : Phandalin / Icespire Peak).</span>
</label>
</div>
</div>
<div class="field">
<label for="arc-create-description">Description</label>
<textarea

View File

@@ -3,6 +3,44 @@
max-width: 640px;
}
.arc-type-choice {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.arc-type-option {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 0.95rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
cursor: pointer;
background: var(--color-surface, #fff);
transition: border-color 120ms ease, background 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
&.selected {
border-color: var(--color-primary, #2c6cd6);
background: rgba(66, 133, 244, 0.06);
}
input[type="radio"] { display: none; }
}
.arc-type-title {
font-weight: 600;
font-size: 0.95rem;
}
.arc-type-desc {
font-size: 0.78rem;
color: var(--color-text-muted, #666);
line-height: 1.3;
}
// Override local : titre en violet (pas en blanc comme le .page-header global).
.page-header h1 { color: #a5b4fc; }

View File

@@ -44,7 +44,9 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: ['']
description: [''],
// Type structurel : LINEAR (séquentiel) par défaut, HUB (sandbox/quêtes parallèles).
type: ['LINEAR', Validators.required]
});
}
@@ -72,6 +74,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
description: this.form.value.description,
campaignId: this.campaignId,
order: this.existingArcCount + 1,
type: this.form.value.type,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),

View File

@@ -71,6 +71,22 @@
</textarea>
</div>
<div class="field">
<label>Structure de l'arc</label>
<div class="arc-type-choice">
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
<input type="radio" formControlName="type" value="LINEAR" />
<span class="arc-type-title">Linéaire</span>
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle.</span>
</label>
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
<input type="radio" formControlName="type" value="HUB" />
<span class="arc-type-title">Hub</span>
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions (sandbox).</span>
</label>
</div>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>

View File

@@ -3,6 +3,44 @@
max-width: 640px;
}
.arc-type-choice {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.arc-type-option {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 0.95rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
cursor: pointer;
background: var(--color-surface, #fff);
transition: border-color 120ms ease, background 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
&.selected {
border-color: var(--color-primary, #2c6cd6);
background: rgba(66, 133, 244, 0.06);
}
input[type="radio"] { display: none; }
}
.arc-type-title {
font-weight: 600;
font-size: 0.95rem;
}
.arc-type-desc {
font-size: 0.78rem;
color: var(--color-text-muted, #666);
line-height: 1.3;
}
// Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header {
display: flex;

View File

@@ -85,6 +85,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
type: ['LINEAR', Validators.required],
themes: [''],
stakes: [''],
gmNotes: [''],
@@ -137,6 +138,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.form.patchValue({
name: arc.name,
description: arc.description ?? '',
type: arc.type ?? 'LINEAR',
themes: arc.themes ?? '',
stakes: arc.stakes ?? '',
gmNotes: arc.gmNotes ?? '',
@@ -155,6 +157,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
description: this.form.value.description,
campaignId: this.campaignId,
order: this.arc.order ?? 1,
type: this.form.value.type,
themes: this.form.value.themes,
stakes: this.form.value.stakes,
gmNotes: this.form.value.gmNotes,

View File

@@ -6,7 +6,9 @@
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ arc.name }}
</h1>
<p class="view-subtitle">Arc narratif</p>
<p class="view-subtitle">
{{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }}
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
@@ -31,6 +33,40 @@
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut.
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
<section class="view-section" *ngIf="arc.type === 'HUB'">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
<p class="view-section-empty" *ngIf="hubQuests.length === 0">
Aucune quête pour ce hub. Créez un chapitre pour ajouter une première quête.
</p>
<div class="hub-quest-grid" *ngIf="hubQuests.length > 0">
<button type="button"
class="hub-quest-card"
*ngFor="let q of hubQuests"
(click)="openQuest(q)">
<div class="hub-quest-card-head">
<span class="hub-quest-card-icon" *ngIf="q.icon">
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon>
</span>
<span class="hub-quest-card-name">{{ q.name }}</span>
</div>
<p class="hub-quest-card-desc" *ngIf="q.description?.trim()">{{ q.description }}</p>
<div class="hub-quest-card-locked-hint" *ngIf="(q.prerequisites?.length ?? 0) > 0">
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span>
<ul class="hub-quest-card-prereq-list">
<li *ngFor="let p of q.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
</div>
</button>
</div>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
<p class="view-section-body" *ngIf="arc.description?.trim(); else emptyDesc">{{ arc.description }}</p>

View File

@@ -1 +1,112 @@
// Styles partagés via styles/_view.scss
// === Vue Hub ===
.hub-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
.view-section-title { margin: 0; }
}
.hub-quest-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.85rem;
}
.hub-quest-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.85rem 0.9rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
background: var(--color-surface, #fff);
text-align: left;
cursor: pointer;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
min-height: 110px;
&:hover {
border-color: var(--color-primary, #2c6cd6);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06);
}
&.locked {
opacity: 0.7;
background: rgba(128, 128, 128, 0.04);
border-style: dashed;
&:hover {
// Pas d'illusion d'interactivité pour les quêtes verrouillées
transform: none;
border-color: var(--color-border, #e2e2e2);
box-shadow: none;
}
}
}
.hub-quest-card-head {
display: flex;
align-items: center;
gap: 0.45rem;
}
.hub-quest-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-primary, #2c6cd6);
}
.hub-quest-card-name {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.2;
}
.hub-quest-card-desc {
font-size: 0.82rem;
color: var(--color-text-muted, #666);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.hub-quest-card-footer {
margin-top: auto;
}
.hub-quest-card-locked-hint {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.4rem;
padding-top: 0.4rem;
border-top: 1px dashed rgba(128, 128, 128, 0.25);
font-size: 0.72rem;
color: var(--color-text-muted, #777);
> lucide-icon,
> span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
}
.hub-quest-card-prereq-list {
list-style: disc;
padding-left: 1rem;
margin: 0.15rem 0 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Arc } from '../../../services/campaign.model';
import { Arc, Chapter, Prerequisite } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
@@ -32,12 +32,22 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly AlertCircle = AlertCircle;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';
arc: Arc | null = null;
/** Chapitres de l'arc courant — exploités pour le rendu HUB (grille de quêtes). */
hubQuests: Chapter[] = [];
/**
* Indexe les chapitres de toute la campagne par id pour résoudre les libellés
* des prérequis QUEST_COMPLETED quand on les affiche dans les tooltips de verrouillage.
*/
private allChaptersById: Record<string, Chapter> = {};
/** ID du Lore associé à la campagne (null si pas d'univers lié). */
loreId: string | null = null;
/** Pages du Lore — pour résoudre relatedPageIds en titres. */
@@ -85,10 +95,42 @@ export class ArcViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(arc.name);
// Quêtes du Hub : chapitres de l'arc courant, triés par order puis par nom.
this.hubQuests = [...(treeData.chaptersByArc[this.arcId] ?? [])].sort((a, b) => {
const oa = a.order ?? 0;
const ob = b.order ?? 0;
if (oa !== ob) return oa - ob;
return a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' });
});
// Index global pour résoudre les noms de quêtes référencées par les prérequis.
this.allChaptersById = {};
Object.values(treeData.chaptersByArc).forEach(list =>
list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; })
);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
/** Construit un libellé lisible pour un prérequis (tooltip de verrouillage). */
describePrerequisite(p: Prerequisite): string {
switch (p.kind) {
case 'QUEST_COMPLETED':
return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`;
case 'SESSION_REACHED':
return `Session ${p.minSessionNumber} atteinte`;
case 'FLAG_SET':
return `Fait : ${p.flagName}`;
}
}
openQuest(q: Chapter): void {
if (!q.id) return;
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', q.id
]);
}
titleOfRelated(pageId: string): string {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}

View File

@@ -30,9 +30,12 @@ export function loadCampaignTreeData(
characterService: CharacterService,
npcService: NpcService
): Observable<CampaignTreeData> {
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent
// doivent passer par PlaythroughService et appeler characterService.getByPlaythrough).
return forkJoin({
arcs: service.getArcs(campaignId),
characters: characterService.getByCampaign(campaignId),
characters: of([] as Character[]),
npcs: npcService.getByCampaign(campaignId)
}).pipe(
switchMap(({ arcs, characters, npcs }) => {

View File

@@ -196,60 +196,38 @@
</div>
</section>
<!-- ============ Sessions de jeu ============ -->
<section class="detail-section sessions-section" *ngIf="!editing">
<!-- ============ Parties (Playthroughs) ============ -->
<section class="detail-section playthroughs-section" *ngIf="!editing">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Sessions de jeu
Mes parties
</h2>
<!-- Cas 1 : aucune session active dans l'app → on peut lancer -->
<button *ngIf="!activeSessionGlobal"
class="btn-add"
[disabled]="startingSession"
(click)="startSession()">
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
Lancer une nouvelle session
</button>
<!-- Cas 2 : session active sur cette campagne → reprendre -->
<button *ngIf="activeSessionOnCurrentCampaign"
class="btn-add"
(click)="openSession(activeSessionOnCurrentCampaign)">
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
Reprendre la session en cours
</button>
<!-- Cas 3 : session active sur une autre campagne → bloqué -->
<button *ngIf="isLaunchBlockedByOtherCampaign"
class="btn-add"
disabled
title="Une session est déjà en cours sur une autre campagne. Termine-la d'abord.">
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
Session en cours ailleurs
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle partie
</button>
</div>
<div class="sessions-grid" *ngIf="sessions.length > 0">
<div class="session-card"
*ngFor="let session of sessions"
[class.session-card--active]="session.active"
(click)="openSession(session)">
<lucide-icon [img]="Dices" [size]="20" class="session-icon"></lucide-icon>
<div class="session-info">
<span class="session-name">{{ session.name }}</span>
<span class="session-meta">
<span class="session-status" *ngIf="session.active">● En cours</span>
<span *ngIf="!session.active">Terminée le {{ session.endedAt | date:'dd/MM/yyyy' }}</span>
</span>
<div class="playthroughs-grid" *ngIf="playthroughs.length > 0">
<div class="playthrough-card"
*ngFor="let p of playthroughs"
(click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span>
<span class="playthrough-meta" *ngIf="p.description">{{ p.description }}</span>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="sessions.length === 0">
<p>Aucune session de jeu pour le moment.</p>
<div class="empty-state empty-state--compact" *ngIf="playthroughs.length === 0">
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
</div>
</section>
<!-- Sessions retirées : elles vivent désormais dans une Partie.
Les faits narratifs sont définis dans les conditions de chaque quête
(chapter-edit > « Conditions de déblocage »). -->
</div>

View File

@@ -1,3 +1,58 @@
.section-hint {
margin: -0.25rem 0 0.85rem;
font-size: 0.85rem;
color: var(--color-text-muted, #666);
max-width: 70ch;
line-height: 1.4;
}
// ==== Parties (Playthroughs) ====
.playthroughs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.playthrough-card {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
background: var(--color-surface, #fff);
cursor: pointer;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
&:hover {
border-color: var(--color-primary, #2c6cd6);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06);
}
}
.playthrough-icon { color: var(--color-primary, #2c6cd6); flex: 0 0 auto; }
.playthrough-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.playthrough-name {
font-weight: 600;
font-size: 0.95rem;
}
.playthrough-meta {
font-size: 0.78rem;
color: var(--color-text-muted, #777);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.campaign-detail {
padding: 2.5rem 2rem;
display: flex;

View File

@@ -13,6 +13,8 @@ import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { SessionService } from '../../../services/session.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { Playthrough } from '../../../services/campaign.model';
import { Session } from '../../../services/session.model';
import { Character } from '../../../services/character.model';
import { Npc } from '../../../services/npc.model';
@@ -69,6 +71,11 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
/** Indicateur de lancement en cours pour éviter les double-clics. */
startingSession = false;
/** Parties (Playthroughs) de cette campagne. */
playthroughs: Playthrough[] = [];
/** Partie par défaut (1re) — fallback pour les actions session/PJ tant que l'UI Playthrough n'est pas finie. */
defaultPlaythroughId: string | null = null;
/** Mode édition inline. */
editing = false;
editName = '';
@@ -92,6 +99,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private characterService: CharacterService,
private npcService: NpcService,
private sessionService: SessionService,
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
@@ -109,11 +117,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
)
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}))
).subscribe(({ campaign, allCampaigns, treeData }) => {
).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
@@ -145,10 +156,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
@@ -186,9 +200,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
).subscribe(gs => this.linkedGameSystem = gs);
}
/** Charge les fiches de personnages (PJ) de la campagne. */
private loadCharacters(campaignId: string): void {
this.characterService.getByCampaign(campaignId).pipe(
/** Charge les PJ de la Partie par défaut (refonte Playthrough). */
private loadCharacters(_campaignId: string): void {
if (!this.defaultPlaythroughId) {
this.characters = [];
return;
}
this.characterService.getByPlaythrough(this.defaultPlaythroughId).pipe(
catchError(() => of([] as Character[]))
).subscribe(list => this.characters = list);
}
@@ -200,55 +218,33 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
).subscribe(list => this.npcs = list);
}
/**
* Charge les sessions de cette campagne ET la session active globale.
* La session globale conditionne si le bouton "Lancer" est activable
* (règle métier : une seule session active simultanément dans l'app).
*/
private loadSessions(campaignId: string): void {
this.sessionService.getSessions(campaignId).pipe(
catchError(() => of([] as Session[]))
).subscribe(list => this.sessions = list);
// Sessions retirées de cette vue (vivent dans Playthrough Detail).
private loadSessions(_campaignId: string): void { this.sessions = []; }
this.sessionService.getActiveSession().pipe(
catchError(() => of(null))
).subscribe(active => this.activeSessionGlobal = active);
// ─────────────── Playthroughs (Parties) ───────────────
openPlaythrough(p: Playthrough): void {
this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', p.id]);
}
/** True si une session est active mais sur une AUTRE campagne (lancement bloqué). */
get isLaunchBlockedByOtherCampaign(): boolean {
return !!this.activeSessionGlobal
&& !!this.campaign
&& this.activeSessionGlobal.campaignId !== this.campaign.id;
}
/** Session active sur la campagne courante (le MJ joue déjà ici). */
get activeSessionOnCurrentCampaign(): Session | null {
if (!this.activeSessionGlobal || !this.campaign) return null;
return this.activeSessionGlobal.campaignId === this.campaign.id
? this.activeSessionGlobal
: null;
}
startSession(): void {
if (!this.campaign || this.startingSession || this.isLaunchBlockedByOtherCampaign) return;
this.startingSession = true;
this.sessionService.startSession(this.campaign.id!).subscribe({
next: session => {
this.startingSession = false;
this.router.navigate(['/sessions', session.id]);
/** Crée une nouvelle Partie avec un nom par défaut, puis y navigue. */
newPlaythroughInFlight = false;
createPlaythrough(): void {
if (!this.campaign || this.newPlaythroughInFlight) return;
this.newPlaythroughInFlight = true;
const defaultName = `Nouvelle partie ${this.playthroughs.length + 1}`;
this.playthroughService.create({
campaignId: this.campaign.id!,
name: defaultName
}).subscribe({
next: created => {
this.newPlaythroughInFlight = false;
this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', created.id]);
},
error: () => {
this.startingSession = false;
console.error('Erreur lors du lancement de la session');
}
error: () => { this.newPlaythroughInFlight = false; }
});
}
openSession(session: Session): void {
this.router.navigate(['/sessions', session.id]);
}
createCharacter(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
@@ -444,7 +440,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`);
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
if (impact.playthroughs > 0) parts.push(`${impact.playthroughs} partie${impact.playthroughs > 1 ? 's' : ''} (avec ses PJ, sessions, faits)`);
const details: string[] = [];
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);

View File

@@ -26,6 +26,22 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. -->
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'">
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2>
<small class="field-hint">
Définition du scénario : toutes les conditions doivent être remplies (ET) pour
que la quête soit débloquée dans une Partie. La progression en cours et l'état
des faits se gèrent dans l'écran de la Partie, pas ici.
</small>
<app-prerequisite-editor
[prerequisites]="prerequisites"
[availableQuests]="availableQuests"
[availableFlags]="availableFlagNames"
(prerequisitesChange)="onPrerequisitesChange($event)">
</app-prerequisite-editor>
</div>
<!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field">
<label>Illustrations</label>

View File

@@ -3,6 +3,57 @@
max-width: 640px;
}
// === Section Hub : prérequis + progression ===
.hub-section {
border: 1px solid rgba(66, 133, 244, 0.25);
background: rgba(66, 133, 244, 0.03);
border-radius: 12px;
padding: 1rem 1.1rem 1.25rem;
margin-bottom: 1rem;
}
.hub-section-title {
margin: 0 0 0.85rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-primary, #2c6cd6);
}
.progression-choice {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.progression-option {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 8px;
text-align: center;
font-size: 0.88rem;
cursor: pointer;
background: var(--color-surface, #fff);
transition: border-color 120ms ease, background 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
&.selected {
border-color: var(--color-primary, #2c6cd6);
background: rgba(66, 133, 244, 0.08);
font-weight: 600;
}
input[type="radio"] { display: none; }
}
.hub-flags-panel {
margin-top: 0.75rem;
padding: 0.85rem;
border: 1px dashed var(--color-border, #d8d8d8);
border-radius: 8px;
background: var(--color-surface, #fff);
}
// Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header {
display: flex;

View File

@@ -11,27 +11,38 @@ import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Chapter } from '../../../services/campaign.model';
import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { PrerequisiteEditorComponent } from '../../../shared/prerequisite-editor/prerequisite-editor.component';
import { CampaignFlagService } from '../../../services/campaign-flag.service';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de détail/modification d'un Chapitre.
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId
* Écran d'édition d'un Chapitre. Donnée de SCÉNARIO uniquement.
*
* Inclut le picker de pages Lore (B2 cross-context) si la campagne parente
* est associée à un Lore.
* Depuis Playthrough : la progression et le toggle des flags se font dans le
* contexte d'une Partie (voir Playthrough Detail). On garde ici uniquement les
* prérequis (conditions de déblocage), qui font partie du scénario.
*/
@Component({
selector: 'app-chapter-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
imports: [
CommonModule,
ReactiveFormsModule,
LucideAngularModule,
LoreLinkPickerComponent,
AiChatDrawerComponent,
ImageGalleryComponent,
IconPickerComponent,
PrerequisiteEditorComponent
],
templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss']
})
@@ -41,7 +52,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
readonly chatQuickSuggestions = [
'Propose des objectifs clairs pour les joueurs dans ce chapitre',
@@ -63,6 +73,18 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
/** Prérequis (donnée de scénario). */
prerequisites: Prerequisite[] = [];
/** Quêtes candidates pour QUEST_COMPLETED (chapitres de la campagne, sauf celui-ci). */
availableQuests: Chapter[] = [];
/** Faits déclarés au niveau Campagne (autocomplete FLAG_SET). */
availableFlagNames: string[] = [];
/** L'arc parent — pour conditionner la section Hub (prérequis pertinents). */
parentArc: Arc | null = null;
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
@@ -73,7 +95,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private confirmDialog: ConfirmDialogService,
private campaignFlagService: CampaignFlagService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -85,9 +108,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
// réutilise le composant quand on navigue entre chapitres frères via
// l'arbre (même route pattern), et ngOnInit ne se relance pas.
this.route.paramMap.subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
@@ -124,6 +144,21 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.selectedIcon = chapter.icon ?? null;
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.prerequisites = [...(chapter.prerequisites ?? [])];
const allChapters: Chapter[] = [];
Object.values(treeData.chaptersByArc).forEach(list => allChapters.push(...list));
this.availableQuests = allChapters.filter(c => c.id !== this.chapterId);
this.parentArc = treeData.arcs.find(a => a.id === this.arcId) ?? null;
// Autocomplete des FLAG_SET : les noms de faits déjà référencés ailleurs
// dans la campagne (déduit des autres quêtes).
this.campaignFlagService.listReferenced(this.campaignId).subscribe({
next: names => { this.availableFlagNames = names; }
});
this.form.patchValue({
name: chapter.name,
description: chapter.description ?? '',
@@ -136,6 +171,10 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
});
}
onPrerequisitesChange(next: Prerequisite[]): void {
this.prerequisites = next;
}
submit(): void {
if (this.form.invalid || !this.chapter) return;
this.campaignService.updateChapter(this.chapterId, {
@@ -146,6 +185,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
gmNotes: this.form.value.gmNotes,
playerObjectives: this.form.value.playerObjectives,
narrativeStakes: this.form.value.narrativeStakes,
prerequisites: this.prerequisites,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
@@ -176,10 +216,5 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
}
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
ngOnDestroy(): void {}
}

View File

@@ -6,7 +6,7 @@
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">Chapitre</p>
<p class="view-subtitle">{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}</p>
</div>
<div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
@@ -25,6 +25,31 @@
</div>
</header>
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB'">
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
<ul class="view-section-body">
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
<small class="view-section-empty">
Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies.
Le toggle des faits se fait dans l'écran « Faits » de la Partie.
</small>
</ng-container>
<ng-template #noPrereqs>
<p class="view-section-empty">
Aucune condition — cette quête est disponible dès le début dans chaque Partie.
</p>
<p class="view-section-empty">
Cliquez sur <strong>Modifier</strong> pour ajouter des conditions
(« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »).
</p>
</ng-template>
</section>
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>

View File

@@ -1 +1,35 @@
// Styles partagés via styles/_view.scss
.quest-status-bar {
border: 1px solid rgba(66, 133, 244, 0.25);
background: rgba(66, 133, 244, 0.04);
}
.quest-status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.quest-status-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.quest-prereq-summary {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px dashed rgba(66, 133, 244, 0.2);
font-size: 0.85rem;
color: var(--color-text-muted, #555);
ul {
margin: 0.3rem 0 0;
padding-left: 1.2rem;
li { margin-bottom: 0.15rem; }
}
}

View File

@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Chapter } from '../../../services/campaign.model';
import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
@@ -38,9 +38,11 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
arcId = '';
chapterId = '';
chapter: Chapter | null = null;
parentArc: Arc | null = null;
loreId: string | null = null;
availablePages: Page[] = [];
private allChaptersById: Record<string, Chapter> = {};
constructor(
private route: ActivatedRoute,
@@ -88,10 +90,28 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(chapter.name);
// Arc parent (pour conditionner les sections Hub) + index des quêtes par id.
this.parentArc = treeData.arcs.find(a => a.id === this.arcId) ?? null;
this.allChaptersById = {};
Object.values(treeData.chaptersByArc).forEach(list =>
list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; })
);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
describePrerequisite(p: Prerequisite): string {
switch (p.kind) {
case 'QUEST_COMPLETED':
return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`;
case 'SESSION_REACHED':
return `Session ${p.minSessionNumber} atteinte`;
case 'FLAG_SET':
return `Fait : ${p.flagName}`;
}
}
titleOfRelated(pageId: string): string {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}

View File

@@ -5,6 +5,7 @@ import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
@@ -48,6 +49,8 @@ export class CharacterEditComponent implements OnInit {
toggleChat(): void { this.chatOpen = !this.chatOpen; }
campaignId: string | null = null;
/** Partie cible — déduite du 1er Playthrough de la campagne. */
playthroughId: string | null = null;
characterId: string | null = null;
name = '';
@@ -64,6 +67,7 @@ export class CharacterEditComponent implements OnInit {
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private playthroughService: PlaythroughService,
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
@@ -77,6 +81,10 @@ export class CharacterEditComponent implements OnInit {
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
// Résolution Partie par défaut (1er Playthrough) — nécessaire pour create/update.
this.playthroughService.listByCampaign(this.campaignId).subscribe({
next: list => { this.playthroughId = list.length > 0 ? list[0].id! : null; }
});
}
if (this.characterId) {
@@ -113,7 +121,7 @@ export class CharacterEditComponent implements OnInit {
submit(): void {
if (!this.name.trim() || !this.campaignId) return;
if (!this.name.trim() || !this.campaignId || !this.playthroughId) return;
const payload = {
name: this.name.trim(),
portraitImageId: this.portraitImageId,
@@ -121,7 +129,7 @@ export class CharacterEditComponent implements OnInit {
values: this.values,
imageValues: this.imageValues,
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
playthroughId: this.playthroughId
};
const isCreation = !this.characterId;
const req = this.characterId

View File

@@ -0,0 +1,66 @@
<div class="playthrough-page" *ngIf="playthrough">
<header class="page-header">
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<div class="header-info">
<h1>{{ playthrough.name }}</h1>
<p class="subtitle" *ngIf="playthrough.description">{{ playthrough.description }}</p>
</div>
<div class="header-actions">
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- Bloc action principal : démarrer ou reprendre la session -->
<section class="play-action">
<button type="button" class="btn-primary big"
*ngIf="!activeOnThis"
[disabled]="startingSession"
(click)="startSession()">
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
Lancer une session
</button>
<button type="button" class="btn-primary big"
*ngIf="activeOnThis"
(click)="openSession(activeOnThis)">
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
Reprendre la session en cours
</button>
<button type="button" class="btn-secondary" (click)="openFlags()">
<lucide-icon [img]="Flag" [size]="14"></lucide-icon>
Faits de la partie
</button>
</section>
<!-- PJ -->
<section class="block">
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2>
<p class="empty" *ngIf="characters.length === 0">Aucun PJ pour cette partie.</p>
<ul class="character-list" *ngIf="characters.length > 0">
<li *ngFor="let c of characters">
<a [routerLink]="['/campaigns', campaignId, 'characters', c.id]">{{ c.name }}</a>
</li>
</ul>
</section>
<!-- Sessions -->
<section class="block">
<h2>Sessions</h2>
<p class="empty" *ngIf="sessions.length === 0">Aucune session encore. Lancez la première !</p>
<ul class="session-list" *ngIf="sessions.length > 0">
<li *ngFor="let s of sessions" (click)="openSession(s)">
<span class="session-name">{{ s.name }}</span>
<span class="session-status" [class.active]="s.active">{{ s.active ? 'En cours' : 'Terminée' }}</span>
</li>
</ul>
</section>
</div>

View File

@@ -0,0 +1,109 @@
.playthrough-page {
padding: 2.5rem 2rem;
max-width: 880px;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
h1 { margin: 0 0 0.2rem; font-size: 1.6rem; }
}
.header-info { flex: 1; }
.subtitle {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-muted, #666);
}
.play-action {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.btn-primary.big,
.btn-secondary.big {
padding: 0.75rem 1.25rem;
font-size: 0.95rem;
}
.block {
margin-bottom: 1.75rem;
h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.05rem;
margin: 0 0 0.7rem;
}
}
.empty {
font-size: 0.88rem;
color: var(--color-text-muted, #777);
font-style: italic;
}
.character-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
a {
display: inline-block;
padding: 0.35rem 0.7rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 999px;
text-decoration: none;
color: inherit;
&:hover { border-color: var(--color-primary, #2c6cd6); }
}
}
.session-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.85rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 8px;
cursor: pointer;
transition: border-color 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
}
}
.session-status {
font-size: 0.78rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: rgba(128, 128, 128, 0.1);
color: var(--color-text-muted, #666);
&.active {
background: rgba(52, 168, 83, 0.15);
color: #2f7a47;
font-weight: 600;
}
}

View File

@@ -0,0 +1,145 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { SessionService } from '../../../services/session.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Playthrough } from '../../../services/campaign.model';
import { Session } from '../../../services/session.model';
import { Character } from '../../../services/character.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Vue détail d'une Partie (Playthrough).
* Minimal MVP — affiche les infos, le lien vers les faits, la liste des sessions
* et les PJ de cette Partie.
*/
@Component({
selector: 'app-playthrough-detail',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule],
templateUrl: './playthrough-detail.component.html',
styleUrls: ['./playthrough-detail.component.scss']
})
export class PlaythroughDetailComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Play = Play;
readonly Flag = Flag;
readonly Users = Users;
readonly Trash2 = Trash2;
readonly Pencil = Pencil;
campaignId = '';
playthroughId = '';
playthrough: Playthrough | null = null;
sessions: Session[] = [];
characters: Character[] = [];
/** Session active de CETTE Partie (null si aucune). Plus de check global. */
activeOnThis: Session | null = null;
startingSession = false;
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private playthroughService: PlaythroughService,
private sessionService: SessionService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
const cid = pm.get('campaignId')!;
const pid = pm.get('playthroughId')!;
if (cid !== this.campaignId || pid !== this.playthroughId) {
this.campaignId = cid;
this.playthroughId = pid;
this.load();
}
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService),
playthrough: this.playthroughService.getById(this.playthroughId),
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),
activeOnThis: this.sessionService.getActiveByPlaythrough(this.playthroughId).pipe(catchError(() => of(null)))
}).subscribe(({ campaign, allCampaigns, treeData, playthrough, sessions, characters, activeOnThis }) => {
this.playthrough = playthrough;
this.sessions = sessions;
this.characters = characters;
this.activeOnThis = activeOnThis;
this.pageTitleService.set(`${playthrough.name}${campaign.name}`);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
startSession(): void {
if (this.startingSession || this.activeOnThis) return;
this.startingSession = true;
this.sessionService.startSession(this.playthroughId).subscribe({
next: s => { this.startingSession = false; this.router.navigate(['/sessions', s.id]); },
error: () => { this.startingSession = false; }
});
}
openSession(s: Session): void {
this.router.navigate(['/sessions', s.id]);
}
openFlags(): void {
this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'flags']);
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
delete(): void {
if (!this.playthrough) return;
this.playthroughService.deletionImpact(this.playthroughId).subscribe({
next: impact => {
const parts: string[] = [];
if (impact.sessions > 0) parts.push(`${impact.sessions} session(s)`);
if (impact.characters > 0) parts.push(`${impact.characters} PJ`);
if (impact.flags > 0) parts.push(`${impact.flags} fait(s)`);
if (impact.progressions > 0) parts.push(`${impact.progressions} progression(s) de quête`);
const details: string[] = [];
if (parts.length) details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
details.push('Cette action est irréversible.');
this.confirmDialog.confirm({
title: 'Supprimer la Partie',
message: `Supprimer "${this.playthrough?.name}" ?`,
details,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.playthroughService.delete(this.playthroughId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId])
});
});
}
});
}
ngOnDestroy(): void {}
}

View File

@@ -0,0 +1,20 @@
<div class="flags-page" *ngIf="playthrough">
<header class="page-header">
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<div>
<h1>Faits — {{ playthrough.name }}</h1>
<p class="subtitle">
Faits booléens propres à cette partie. Toggle-les en jeu pour débloquer
les quêtes qui en dépendent.
</p>
</div>
</header>
<app-playthrough-flags-manager
[campaignId]="campaignId"
[playthroughId]="playthroughId">
</app-playthrough-flags-manager>
</div>

View File

@@ -0,0 +1,24 @@
.flags-page {
padding: 2.5rem 2rem;
max-width: 720px;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
}
.subtitle {
margin: 0;
font-size: 0.88rem;
color: var(--color-text-muted, #666);
max-width: 60ch;
line-height: 1.4;
}

View File

@@ -0,0 +1,75 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Playthrough } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-flags-manager/playthrough-flags-manager.component';
/**
* Page dédiée aux "Faits" d'une Partie.
* Route : /campaigns/:campaignId/playthroughs/:playthroughId/flags
*/
@Component({
selector: 'app-playthrough-flags-page',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent],
templateUrl: './playthrough-flags-page.component.html',
styleUrls: ['./playthrough-flags-page.component.scss']
})
export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
campaignId = '';
playthroughId = '';
playthrough: Playthrough | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
const cid = pm.get('campaignId')!;
const pid = pm.get('playthroughId')!;
if (cid !== this.campaignId || pid !== this.playthroughId) {
this.campaignId = cid;
this.playthroughId = pid;
this.load();
}
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService),
playthrough: this.playthroughService.getById(this.playthroughId)
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
this.playthrough = playthrough;
this.pageTitleService.set(`${playthrough.name} — Faits`);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
ngOnDestroy(): void {}
}

View File

@@ -231,6 +231,18 @@
</small>
</div>
<!-- Lieu explorable : pièces / donjon -->
<app-expandable-section title="Lieu explorable (donjon, crypte…)" icon="🏰" [initiallyOpen]="rooms.length > 0">
<small class="field-hint">
Si cette scène représente un lieu à parcourir pièce par pièce, ajoutez-les ici.
La scène bascule alors en mode « donjon » côté affichage.
</small>
<app-rooms-editor
[rooms]="rooms"
(roomsChange)="onRoomsChange($event)">
</app-rooms-editor>
</app-expandable-section>
</form>
</div>

View File

@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Scene, SceneBranch } from '../../../services/campaign.model';
import { Scene, SceneBranch, Room } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
@@ -19,6 +19,7 @@ import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-l
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { RoomsEditorComponent } from '../../../shared/rooms-editor/rooms-editor.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
@@ -29,7 +30,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
@Component({
selector: 'app-scene-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent],
templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss']
})
@@ -67,6 +68,11 @@ export class SceneEditComponent implements OnInit, OnDestroy {
/** Branches narratives (état local mutable, persisté au submit). */
branches: SceneBranch[] = [];
/** Pièces du lieu explorable (état local, persisté au submit). */
rooms: Room[] = [];
onRoomsChange(next: Room[]): void { this.rooms = next; }
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
@@ -144,6 +150,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.mapImageIds = [...(scene.mapImageIds ?? [])];
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
this.rooms = (scene.rooms ?? []).map(r => ({ ...r, branches: [...(r.branches ?? [])] }));
this.form.patchValue({
name: scene.name,
description: scene.description ?? '',
@@ -180,6 +187,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
branches: this.branches,
rooms: this.rooms,
icon: this.selectedIcon
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),

View File

@@ -100,4 +100,48 @@
</div>
</section>
<!-- Lieu explorable : pièces (mode donjon) -->
<section class="view-section" *ngIf="(scene.rooms?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🏰</span> Pièces du lieu</h2>
<div class="rooms-readonly">
<article class="room-readonly" *ngFor="let r of scene.rooms; let i = index">
<header class="room-readonly-head">
<span class="room-readonly-index">#{{ i + 1 }}</span>
<span class="room-readonly-name">{{ r.name }}</span>
<span class="room-readonly-floor" *ngIf="r.floor !== null && r.floor !== undefined">
Étage {{ r.floor }}
</span>
</header>
<p class="room-readonly-desc" *ngIf="r.description?.trim()">{{ r.description }}</p>
<div class="room-readonly-grid">
<div *ngIf="r.enemies?.trim()">
<strong>⚔️ Ennemis</strong>
<p>{{ r.enemies }}</p>
</div>
<div *ngIf="r.loot?.trim()">
<strong>💰 Loot</strong>
<p>{{ r.loot }}</p>
</div>
<div *ngIf="r.traps?.trim()">
<strong>⚠️ Pièges</strong>
<p>{{ r.traps }}</p>
</div>
<div *ngIf="r.gmNotes?.trim()" class="room-readonly-private">
<strong>🔒 Notes MJ</strong>
<p>{{ r.gmNotes }}</p>
</div>
</div>
<div class="room-readonly-branches" *ngIf="(r.branches?.length ?? 0) > 0">
<strong>→ Sorties</strong>
<ul>
<li *ngFor="let b of r.branches">
<em>{{ b.label }}</em> → {{ roomNameById(scene, b.targetRoomId) }}
<span class="branch-cond" *ngIf="b.condition?.trim()">(si : {{ b.condition }})</span>
</li>
</ul>
</div>
</article>
</div>
</section>
</div>

View File

@@ -1 +1,84 @@
// Styles partagés via styles/_view.scss
.rooms-readonly {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.room-readonly {
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
padding: 0.85rem 1rem;
background: var(--color-surface, #fff);
}
.room-readonly-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.room-readonly-index {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.78rem;
color: var(--color-text-muted, #777);
}
.room-readonly-name {
font-weight: 600;
font-size: 1rem;
flex: 1;
}
.room-readonly-floor {
font-size: 0.78rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: rgba(66, 133, 244, 0.1);
color: #2c6cd6;
}
.room-readonly-desc {
margin: 0 0 0.6rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--color-text, #333);
white-space: pre-wrap;
}
.room-readonly-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.7rem;
margin-bottom: 0.5rem;
> div {
strong { display: block; font-size: 0.82rem; margin-bottom: 0.2rem; color: var(--color-text-muted, #555); }
p { margin: 0; font-size: 0.88rem; white-space: pre-wrap; }
}
}
.room-readonly-private {
background: rgba(192, 57, 43, 0.05);
padding: 0.4rem 0.55rem;
border-radius: 6px;
border-left: 3px solid rgba(192, 57, 43, 0.4);
}
.room-readonly-branches {
margin-top: 0.45rem;
padding-top: 0.45rem;
border-top: 1px dashed var(--color-border, #ececec);
font-size: 0.85rem;
strong { display: block; font-size: 0.82rem; margin-bottom: 0.15rem; color: var(--color-text-muted, #555); }
ul { margin: 0; padding-left: 1.1rem; }
li { margin-bottom: 0.1rem; }
}
.branch-cond {
color: var(--color-text-muted, #777);
font-style: italic;
}

View File

@@ -99,6 +99,11 @@ export class SceneViewComponent implements OnInit, OnDestroy {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}
/** Résout le nom d'une pièce cible (pour afficher les sorties inter-pièces). */
roomNameById(scene: Scene | null, roomId: string): string {
return scene?.rooms?.find(r => r.id === roomId)?.name ?? '(pièce supprimée)';
}
editMode(): void {
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId,

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* Liste les noms de faits référencés par les conditions des quêtes d'une Campagne.
* Modèle "déclaration implicite" : un fait existe dès qu'au moins une quête le
* référence dans ses prérequis FLAG_SET.
*/
@Injectable({ providedIn: 'root' })
export class CampaignFlagService {
constructor(private http: HttpClient) {}
/** Noms de faits dédupliqués et triés. */
listReferenced(campaignId: string): Observable<string[]> {
return this.http.get<string[]>(`/api/campaigns/${campaignId}/flags`);
}
}

View File

@@ -21,6 +21,27 @@ export interface CampaignCreate {
gameSystemId?: string | null;
}
/** Type structurel d'un Arc (miroir de l'enum Java ArcType). */
export type ArcType = 'LINEAR' | 'HUB';
/** Statut de progression piloté manuellement par le MJ (persisté). */
export type ProgressionStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED';
/**
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
* Calculé côté backend — ne jamais le re-dériver côté front.
*/
export type QuestStatus = 'LOCKED' | 'AVAILABLE' | 'IN_PROGRESS' | 'COMPLETED';
/**
* Condition de déblocage d'une quête. Union TS discriminée sur `kind` —
* miroir du sealed type Java Prerequisite. Le narrowing TS marche sur le `kind`.
*/
export type Prerequisite =
| { kind: 'QUEST_COMPLETED'; questId: string }
| { kind: 'SESSION_REACHED'; minSessionNumber: number }
| { kind: 'FLAG_SET'; flagName: string };
export interface Arc {
id?: string;
name: string;
@@ -29,6 +50,9 @@ export interface Arc {
order?: number;
chapterCount?: number;
/** Type structurel (défaut LINEAR côté backend si omis). */
type?: ArcType;
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS). */
icon?: string | null;
@@ -55,6 +79,7 @@ export interface ArcCreate {
description?: string;
campaignId: string;
order: number;
type?: ArcType;
icon?: string | null;
themes?: string;
@@ -76,6 +101,20 @@ export interface Chapter {
order?: number;
icon?: string | null;
/** Conditions de déblocage (ET logique). Donnée de SCÉNARIO. */
prerequisites?: Prerequisite[];
/**
* Statut de progression read-only — peuplé par le backend uniquement quand
* un playthroughId est passé en query param. Ne pas inclure en écriture.
*/
progressionStatus?: ProgressionStatus;
/**
* Statut effectif read-only — idem, dépend du Playthrough.
*/
effectiveStatus?: QuestStatus;
// Champs narratifs enrichis
gmNotes?: string;
playerObjectives?: string;
@@ -93,6 +132,8 @@ export interface ChapterCreate {
order: number;
icon?: string | null;
prerequisites?: Prerequisite[];
gmNotes?: string;
playerObjectives?: string;
narrativeStakes?: string;
@@ -102,6 +143,35 @@ export interface ChapterCreate {
mapImageIds?: string[];
}
/**
* Partie / instance jouée d'une Campagne par une table donnée.
* Porte la progression dynamique des quêtes, les flags et les sessions.
*/
export interface Playthrough {
id?: string;
campaignId: string;
name: string;
description?: string;
createdAt?: string;
updatedAt?: string;
}
export interface PlaythroughCreate {
campaignId: string;
name: string;
description?: string;
}
/**
* Valeur courante d'un fait pour une Partie donnée.
* Pas de déclaration explicite : un fait existe dès qu'au moins une quête le
* référence via un prérequis FLAG_SET.
*/
export interface PlaythroughFlag {
name: string;
value: boolean;
}
/**
* Branche narrative : sortie possible d'une scène vers une autre du même chapitre.
* Pendant TS du Value Object Java SceneBranch.
@@ -112,6 +182,37 @@ export interface SceneBranch {
condition?: string;
}
/**
* Sortie d'une pièce vers une autre pièce du même lieu explorable.
* Pendant TS du record domaine RoomBranch.
*/
export interface RoomBranch {
label: string;
targetRoomId: string;
condition?: string;
}
/**
* Pièce d'un lieu explorable attachée à une Scene.
* Dès qu'une Scene a au moins une Room, elle bascule sur le rendu « donjon ».
*/
export interface Room {
/** UUID stable généré côté client à la création (sert de cible aux RoomBranch). */
id: string;
name: string;
description?: string;
enemies?: string;
loot?: string;
traps?: string;
gmNotes?: string;
/** Étage : 0 = RdC, 1 = 1er etc. null/undefined = pas d'étage défini. */
floor?: number | null;
order: number;
illustrationImageIds?: string[];
mapImageId?: string | null;
branches?: RoomBranch[];
}
export interface Scene {
id?: string;
name: string;
@@ -136,6 +237,9 @@ export interface Scene {
/** Sorties narratives (graphe intra-chapitre). */
branches?: SceneBranch[];
/** Pièces explorables. Vide = scène classique, non-vide = lieu type donjon. */
rooms?: Room[];
}
export interface SceneCreate {
@@ -158,4 +262,5 @@ export interface SceneCreate {
illustrationImageIds?: string[];
mapImageIds?: string[];
branches?: SceneBranch[];
rooms?: Room[];
}

View File

@@ -8,7 +8,7 @@ export interface CampaignDeletionImpact {
arcs: number;
chapters: number;
scenes: number;
characters: number;
playthroughs: number;
}
/** Compte des entités qui seront supprimées en cascade avec un arc. */
@@ -85,13 +85,20 @@ export class CampaignService {
}
// ========== CHAPTER ==========
getChapters(arcId: string): Observable<Chapter[]> {
const params = new HttpParams().set('arcId', arcId);
/**
* Liste les chapitres d'un arc. Si {@code playthroughId} est fourni, le backend
* enrichit les DTOs avec progressionStatus + effectiveStatus relatifs à la Partie.
*/
getChapters(arcId: string, playthroughId?: string): Observable<Chapter[]> {
let params = new HttpParams().set('arcId', arcId);
if (playthroughId) params = params.set('playthroughId', playthroughId);
return this.http.get<Chapter[]>('/api/chapters', { params });
}
getChapterById(id: string): Observable<Chapter> {
return this.http.get<Chapter>(`/api/chapters/${id}`);
getChapterById(id: string, playthroughId?: string): Observable<Chapter> {
let params = new HttpParams();
if (playthroughId) params = params.set('playthroughId', playthroughId);
return this.http.get<Chapter>(`/api/chapters/${id}`, { params });
}
createChapter(payload: ChapterCreate): Observable<Chapter> {

View File

@@ -1,10 +1,7 @@
/**
* Fiche de personnage joueur (PJ) d'une campagne.
* Refonte 2026-04-30 : abandon du markdownContent au profit d'un systeme
* template-based pilote par le GameSystem de la campagne.
* - portraitImageId / headerImageId : champs universels hard-codes
* - values : Map<champ template TEXT/NUMBER, valeur>
* - imageValues : Map<champ template IMAGE, liste d'IDs d'images>
* Fiche de personnage joueur (PJ) d'une Partie (Playthrough).
* Refonte Playthrough : les PJ appartiennent à la Partie (table jouée),
* plus à la Campagne (scénario).
*/
export interface Character {
id?: string;
@@ -15,7 +12,7 @@ export interface Character {
imageValues?: Record<string, string[]>;
/** Champs KEY_VALUE_LIST : fieldName -> label -> value. */
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
playthroughId: string;
order?: number;
}
@@ -26,5 +23,5 @@ export interface CharacterCreate {
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
playthroughId: string;
}

View File

@@ -12,8 +12,8 @@ export class CharacterService {
constructor(private http: HttpClient) {}
getByCampaign(campaignId: string): Observable<Character[]> {
return this.http.get<Character[]>(`${this.apiUrl}/campaign/${campaignId}`);
getByPlaythrough(playthroughId: string): Observable<Character[]> {
return this.http.get<Character[]>(`${this.apiUrl}/playthrough/${playthroughId}`);
}
getById(id: string): Observable<Character> {

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { PlaythroughFlag } from './campaign.model';
/**
* Endpoints des flags narratifs d'une Partie (Playthrough).
* Remplace l'ancien CampaignFlagService.
*/
@Injectable({ providedIn: 'root' })
export class PlaythroughFlagService {
constructor(private http: HttpClient) {}
list(playthroughId: string): Observable<PlaythroughFlag[]> {
return this.http.get<PlaythroughFlag[]>(`/api/playthroughs/${playthroughId}/flags`);
}
setFlag(playthroughId: string, name: string, value: boolean): Observable<PlaythroughFlag> {
return this.http.put<PlaythroughFlag>(
`/api/playthroughs/${playthroughId}/flags/${encodeURIComponent(name)}`,
{ name, value }
);
}
deleteFlag(playthroughId: string, name: string): Observable<void> {
return this.http.delete<void>(
`/api/playthroughs/${playthroughId}/flags/${encodeURIComponent(name)}`
);
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Playthrough, PlaythroughCreate } from './campaign.model';
export interface PlaythroughDeletionImpact {
sessions: number;
characters: number;
flags: number;
progressions: number;
}
@Injectable({ providedIn: 'root' })
export class PlaythroughService {
private apiUrl = '/api/playthroughs';
constructor(private http: HttpClient) {}
listByCampaign(campaignId: string): Observable<Playthrough[]> {
const params = new HttpParams().set('campaignId', campaignId);
return this.http.get<Playthrough[]>(this.apiUrl, { params });
}
getById(id: string): Observable<Playthrough> {
return this.http.get<Playthrough>(`${this.apiUrl}/${id}`);
}
create(payload: PlaythroughCreate): Observable<Playthrough> {
return this.http.post<Playthrough>(this.apiUrl, payload);
}
update(id: string, payload: Playthrough): Observable<Playthrough> {
return this.http.put<Playthrough>(`${this.apiUrl}/${id}`, payload);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
deletionImpact(id: string): Observable<PlaythroughDeletionImpact> {
return this.http.get<PlaythroughDeletionImpact>(`${this.apiUrl}/${id}/deletion-impact`);
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ProgressionStatus } from './campaign.model';
/**
* Endpoints de progression des quêtes pour un Playthrough.
* Modèle "absence = NOT_STARTED" — envoyer NOT_STARTED supprime la ligne côté backend.
*/
@Injectable({ providedIn: 'root' })
export class QuestProgressionService {
constructor(private http: HttpClient) {}
/** Map chapterId -> ProgressionStatus pour le Playthrough donné. */
list(playthroughId: string): Observable<Record<string, ProgressionStatus>> {
return this.http.get<Record<string, ProgressionStatus>>(
`/api/playthroughs/${playthroughId}/quest-progressions`
);
}
setStatus(playthroughId: string, chapterId: string, status: ProgressionStatus): Observable<void> {
return this.http.put<void>(
`/api/playthroughs/${playthroughId}/quest-progressions/${chapterId}`,
{ status }
);
}
}

View File

@@ -5,7 +5,7 @@
export interface Session {
id: string;
name: string;
campaignId: string;
playthroughId: string;
startedAt: string;
/** Null/undefined = session en cours. */
endedAt: string | null;

View File

@@ -5,7 +5,7 @@ import { Session } from './session.model';
/**
* Service HTTP pour le Play Context (gestion des Sessions de jeu).
* Port de sortie vers le Backend Java (Architecture Hexagonale).
* Depuis Playthrough : une Session est rattachée à un Playthrough, pas à une Campagne.
*/
@Injectable({
providedIn: 'root'
@@ -15,20 +15,31 @@ export class SessionService {
constructor(private http: HttpClient) {}
/** Lance une nouvelle session sur la campagne donnée. */
startSession(campaignId: string): Observable<Session> {
return this.http.post<Session>(this.apiUrl, { campaignId });
/** Lance une nouvelle session sur la Partie (Playthrough) donnée. */
startSession(playthroughId: string): Observable<Session> {
return this.http.post<Session>(this.apiUrl, { playthroughId });
}
/** Récupère la session active (204 No Content si aucune). */
/**
* Récupère UNE session active dans l'app (legacy, multi-actives possibles désormais).
* Préférer {@link getActiveByPlaythrough} quand on veut le statut d'une Partie précise.
*/
getActiveSession(): Observable<Session | null> {
return this.http.get<Session | null>(`${this.apiUrl}/active`, { observe: 'body' });
}
getSessions(campaignId?: string): Observable<Session[]> {
/** Récupère la session active de la Partie donnée (null si aucune). */
getActiveByPlaythrough(playthroughId: string): Observable<Session | null> {
return this.http.get<Session | null>(`${this.apiUrl}/active`, {
params: new HttpParams().set('playthroughId', playthroughId),
observe: 'body'
});
}
getSessions(playthroughId?: string): Observable<Session[]> {
let params = new HttpParams();
if (campaignId) {
params = params.set('campaignId', campaignId);
if (playthroughId) {
params = params.set('playthroughId', playthroughId);
}
return this.http.get<Session[]>(this.apiUrl, { params });
}

View File

@@ -1,6 +1,6 @@
<div class="session-detail" *ngIf="session">
<a class="back-link" [routerLink]="['/campaigns', session.campaignId]">
<a class="back-link" [routerLink]="campaignId ? ['/campaigns', campaignId] : ['/campaigns']">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
</a>
@@ -175,7 +175,8 @@
<!-- Colonne droite : panneau référence (Dés / Personnages / Scènes) -->
<aside class="play-aside">
<app-session-reference-panel
[campaignId]="session.campaignId"
[campaignId]="campaignId ?? ''"
[playthroughId]="session.playthroughId"
[sessionId]="session.id"
[canAddToJournal]="session.active"
(rolled)="onDiceRolled($event)"

View File

@@ -11,6 +11,7 @@ import { catchError, switchMap, filter, map } from 'rxjs/operators';
import { of } from 'rxjs';
import { SessionService } from '../../services/session.service';
import { Session } from '../../services/session.model';
import { PlaythroughService } from '../../services/playthrough.service';
import {
SessionEntry, SessionEntryInput, EntryType, ENTRY_TYPE_META
} from '../../services/session-entry.model';
@@ -54,6 +55,8 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
readonly entryTypeMeta = ENTRY_TYPE_META;
session: Session | null = null;
/** Résolu via Playthrough.campaignId (Session → Playthrough → Campaign). */
campaignId: string | null = null;
/** Timeline triée du plus récent au plus ancien (DESC) pour l'UX en partie. */
entries: SessionEntry[] = [];
@@ -74,6 +77,7 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private sessionService: SessionService,
private playthroughService: PlaythroughService,
private entryService: SessionEntryService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -93,6 +97,11 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
if (session) {
this.pageTitleService.set(session.name);
this.loadEntries(session.id);
if (session.playthroughId) {
this.playthroughService.getById(session.playthroughId).pipe(
catchError(() => of(null))
).subscribe(pt => { this.campaignId = pt ? pt.campaignId : null; });
}
}
});
}
@@ -168,9 +177,12 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
variant: 'danger'
}).then(ok => {
if (!ok) return;
const campaignId = session.campaignId;
const cid = this.campaignId;
this.sessionService.deleteSession(session.id).subscribe({
next: () => this.router.navigate(['/campaigns', campaignId]),
next: () => {
if (cid) this.router.navigate(['/campaigns', cid]);
else this.router.navigate(['/campaigns']);
},
error: () => console.error('Erreur lors de la suppression de la session')
});
});

View File

@@ -42,6 +42,8 @@ export class SessionReferencePanelComponent implements OnChanges {
readonly Sparkles = Sparkles;
@Input() campaignId!: string;
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
@Input() playthroughId: string | null = null;
@Input() sessionId!: string;
@Input() canAddToJournal = true;
@Output() rolled = new EventEmitter<DiceRollResult>();
@@ -85,7 +87,11 @@ export class SessionReferencePanelComponent implements OnChanges {
private ensureCharactersLoaded(): void {
if (this.charsLoaded || this.loadingChars || !this.campaignId) return;
this.loadingChars = true;
this.characterService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Character[])))
// PJ : propres à la Partie. PNJ : campagne-scope.
const chars$ = this.playthroughId
? this.characterService.getByPlaythrough(this.playthroughId)
: of([] as Character[]);
chars$.pipe(catchError(() => of([] as Character[])))
.subscribe(list => { this.characters = list; this.tryFinishCharsLoad(); });
this.npcService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Npc[])))
.subscribe(list => { this.npcs = list; this.tryFinishCharsLoad(); });

View File

@@ -0,0 +1,21 @@
<div class="flags-manager">
<p class="flags-empty" *ngIf="!loading && rows.length === 0">
Aucun fait référencé. Ajoutez une condition « Quand un fait est vrai » sur
au moins une quête pour qu'il apparaisse ici.
</p>
<ul class="flag-list" *ngIf="rows.length > 0">
<li class="flag-row" *ngFor="let r of rows" [class.flag-on]="r.value">
<label class="flag-toggle">
<input type="checkbox" [checked]="r.value" (change)="toggle(r)" />
<span class="flag-toggle-slider"></span>
</label>
<div class="flag-info">
<span class="flag-name">{{ r.name }}</span>
</div>
<span class="flag-value">{{ r.value ? 'vrai' : 'faux' }}</span>
</li>
</ul>
</div>

View File

@@ -0,0 +1,133 @@
.flags-manager {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.flag-add-row {
display: flex;
gap: 0.5rem;
}
.flag-add-input {
flex: 1;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border, #d8d8d8);
border-radius: 6px;
font-size: 0.9rem;
}
.flags-empty {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted, #777);
font-style: italic;
}
.flag-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.flag-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 8px;
background: var(--color-surface, #fff);
}
.flag-row.flag-on {
border-color: rgba(52, 168, 83, 0.45);
background: rgba(52, 168, 83, 0.05);
}
.flag-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.flag-name {
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 0.88rem;
font-weight: 600;
}
.flag-desc {
font-size: 0.78rem;
color: var(--color-text-muted, #666);
}
.flag-value {
font-size: 0.78rem;
color: var(--color-text-muted, #777);
min-width: 40px;
text-align: right;
}
.flag-remove {
background: transparent;
border: none;
color: var(--color-danger, #c0392b);
cursor: pointer;
padding: 0.25rem;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover { background: rgba(192, 57, 43, 0.08); }
}
// Toggle switch
.flag-toggle {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
flex: 0 0 36px;
input {
opacity: 0;
width: 0;
height: 0;
&:checked + .flag-toggle-slider {
background: var(--color-primary, #34a853);
}
&:checked + .flag-toggle-slider::before {
transform: translateX(16px);
}
}
}
.flag-toggle-slider {
position: absolute;
inset: 0;
background: #ccc;
border-radius: 999px;
cursor: pointer;
transition: background 150ms ease;
&::before {
content: '';
position: absolute;
height: 16px;
width: 16px;
left: 2px;
top: 2px;
background: #fff;
border-radius: 50%;
transition: transform 150ms ease;
}
}

View File

@@ -0,0 +1,70 @@
import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignFlagService } from '../../services/campaign-flag.service';
import { PlaythroughFlagService } from '../../services/playthrough-flag.service';
import { forkJoin } from 'rxjs';
/**
* Toggle de l'état des faits d'une Partie.
*
* <p>La liste des faits est déduite des conditions FLAG_SET référencées par les
* quêtes de la campagne. Pour chaque fait référencé, on affiche un toggle qui
* met à jour la valeur du Playthrough courant.</p>
*/
@Component({
selector: 'app-playthrough-flags-manager',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './playthrough-flags-manager.component.html',
styleUrls: ['./playthrough-flags-manager.component.scss']
})
export class PlaythroughFlagsManagerComponent implements OnInit, OnChanges {
@Input() campaignId!: string;
@Input() playthroughId!: string;
rows: { name: string; value: boolean }[] = [];
loading = false;
constructor(
private campaignFlagService: CampaignFlagService,
private playthroughFlagService: PlaythroughFlagService
) {}
ngOnInit(): void { this.reload(); }
ngOnChanges(changes: SimpleChanges): void {
if (changes['campaignId'] || changes['playthroughId']) this.reload();
}
reload(): void {
if (!this.campaignId || !this.playthroughId) {
this.rows = [];
return;
}
this.loading = true;
forkJoin({
referenced: this.campaignFlagService.listReferenced(this.campaignId),
values: this.playthroughFlagService.list(this.playthroughId)
}).subscribe({
next: ({ referenced, values }) => {
const valueByName: Record<string, boolean> = {};
for (const v of values) valueByName[v.name] = v.value;
this.rows = referenced.map(name => ({
name,
value: valueByName[name] === true
}));
this.loading = false;
},
error: () => { this.loading = false; }
});
}
toggle(row: { name: string; value: boolean }): void {
const next = !row.value;
this.playthroughFlagService.setFlag(this.playthroughId, row.name, next).subscribe({
next: () => { row.value = next; }
});
}
}

View File

@@ -0,0 +1,79 @@
<div class="prereq-editor">
<p class="prereq-editor-empty" *ngIf="prerequisites.length === 0">
Aucune condition — la quête est immédiatement disponible.
</p>
<ul class="prereq-list" *ngIf="prerequisites.length > 0">
<li class="prereq-row" *ngFor="let p of prerequisites; let i = index; trackBy: trackByIndex">
<ng-container [ngSwitch]="p.kind">
<!-- QUEST_COMPLETED -->
<ng-container *ngSwitchCase="'QUEST_COMPLETED'">
<span class="prereq-row-label">Quête terminée :</span>
<select class="prereq-input"
[ngModel]="asQuestCompleted(p).questId"
(ngModelChange)="onQuestIdChange(i, $event)"
[ngModelOptions]="{ standalone: true }"
[disabled]="availableQuests.length === 0">
<option *ngIf="availableQuests.length === 0" value="">(aucune autre quête)</option>
<option *ngFor="let q of availableQuests" [value]="q.id">{{ q.name }}</option>
</select>
</ng-container>
<!-- SESSION_REACHED -->
<ng-container *ngSwitchCase="'SESSION_REACHED'">
<span class="prereq-row-label">À partir de la session :</span>
<input type="number"
class="prereq-input prereq-input-number"
min="1"
[ngModel]="asSessionReached(p).minSessionNumber"
(ngModelChange)="onMinSessionChange(i, $event)"
[ngModelOptions]="{ standalone: true }" />
</ng-container>
<!-- FLAG_SET -->
<ng-container *ngSwitchCase="'FLAG_SET'">
<span class="prereq-row-label">Quand le fait est vrai :</span>
<input type="text"
class="prereq-input"
placeholder="nom_du_fait"
[attr.list]="'prereq-flag-list-' + i"
[ngModel]="asFlagSet(p).flagName"
(ngModelChange)="onFlagNameChange(i, $event)"
[ngModelOptions]="{ standalone: true }" />
<datalist [attr.id]="'prereq-flag-list-' + i">
<option *ngFor="let f of availableFlags" [value]="f"></option>
</datalist>
</ng-container>
</ng-container>
<button type="button" class="prereq-remove" (click)="removeAt(i)" title="Retirer ce prérequis">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
</ul>
<div class="prereq-add">
<button type="button" class="btn-secondary prereq-add-btn" (click)="toggleAddMenu()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Ajouter une condition
<lucide-icon [img]="ChevronDown" [size]="14"></lucide-icon>
</button>
<div class="prereq-add-menu" *ngIf="addMenuOpen">
<button type="button" (click)="addPrerequisite('QUEST_COMPLETED')">
Après une autre quête
</button>
<button type="button" (click)="addPrerequisite('SESSION_REACHED')">
À partir d'une session
</button>
<button type="button" (click)="addPrerequisite('FLAG_SET')">
Quand un fait est vrai
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,108 @@
.prereq-editor {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.prereq-editor-empty {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted, #777);
font-style: italic;
}
.prereq-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.prereq-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 8px;
background: var(--color-surface, #fff);
}
.prereq-row-label {
font-size: 0.85rem;
color: var(--color-text-muted, #555);
white-space: nowrap;
}
.prereq-input {
flex: 1;
min-width: 0;
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-border, #d8d8d8);
border-radius: 6px;
font-size: 0.88rem;
background: var(--color-bg, #fafafa);
&:focus {
outline: none;
border-color: var(--color-primary, #2c6cd6);
}
}
.prereq-input-number {
flex: 0 0 90px;
}
.prereq-remove {
background: transparent;
border: none;
color: var(--color-danger, #c0392b);
cursor: pointer;
padding: 0.3rem;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover { background: rgba(192, 57, 43, 0.08); }
}
.prereq-add {
position: relative;
align-self: flex-start;
}
.prereq-add-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.7rem;
}
.prereq-add-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 5;
display: flex;
flex-direction: column;
min-width: 220px;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
button {
background: transparent;
border: none;
text-align: left;
padding: 0.55rem 0.75rem;
font-size: 0.88rem;
cursor: pointer;
&:hover { background: rgba(66, 133, 244, 0.08); }
}
}

View File

@@ -0,0 +1,99 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular';
import { Chapter, Prerequisite } from '../../services/campaign.model';
/**
* Éditeur des prérequis (conditions de déblocage) d'une quête.
* Composant standalone & contrôlé : reçoit la liste, émet la nouvelle liste à chaque changement.
*
* Combinaison ET (MVP). UI : liste de lignes + bouton "Ajouter une condition" qui ouvre
* un menu à 3 entrées (quête, session, fait).
*/
@Component({
selector: 'app-prerequisite-editor',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './prerequisite-editor.component.html',
styleUrls: ['./prerequisite-editor.component.scss']
})
export class PrerequisiteEditorComponent {
/** Liste courante. */
@Input() prerequisites: Prerequisite[] = [];
/** Quêtes candidates pour QUEST_COMPLETED (typiquement les chapitres frères du Hub). */
@Input() availableQuests: Chapter[] = [];
/** Flags déjà connus de la campagne (autocomplete pour FLAG_SET). */
@Input() availableFlags: string[] = [];
/** Émis chaque fois que la liste change (toute édition / ajout / suppression). */
@Output() prerequisitesChange = new EventEmitter<Prerequisite[]>();
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
/** Ouvre/ferme le menu d'ajout. */
addMenuOpen = false;
toggleAddMenu(): void { this.addMenuOpen = !this.addMenuOpen; }
addPrerequisite(kind: Prerequisite['kind']): void {
let next: Prerequisite;
switch (kind) {
case 'QUEST_COMPLETED':
next = { kind: 'QUEST_COMPLETED', questId: this.availableQuests[0]?.id ?? '' };
break;
case 'SESSION_REACHED':
next = { kind: 'SESSION_REACHED', minSessionNumber: 1 };
break;
case 'FLAG_SET':
next = { kind: 'FLAG_SET', flagName: this.availableFlags[0] ?? '' };
break;
}
this.prerequisites = [...this.prerequisites, next];
this.prerequisitesChange.emit(this.prerequisites);
this.addMenuOpen = false;
}
removeAt(index: number): void {
this.prerequisites = this.prerequisites.filter((_, i) => i !== index);
this.prerequisitesChange.emit(this.prerequisites);
}
/** Le champ binding change : on émet une nouvelle liste (immutable update). */
updateAt(index: number, patched: Prerequisite): void {
this.prerequisites = this.prerequisites.map((p, i) => (i === index ? patched : p));
this.prerequisitesChange.emit(this.prerequisites);
}
// === Handlers spécifiques par type (pour garder les templates simples) ===
onQuestIdChange(index: number, questId: string): void {
this.updateAt(index, { kind: 'QUEST_COMPLETED', questId });
}
onMinSessionChange(index: number, n: number): void {
this.updateAt(index, { kind: 'SESSION_REACHED', minSessionNumber: Number(n) || 1 });
}
onFlagNameChange(index: number, flagName: string): void {
this.updateAt(index, { kind: 'FLAG_SET', flagName });
}
/** trackBy stable pour *ngFor (sinon les inputs reset à chaque édition). */
trackByIndex(i: number): number { return i; }
// === Casts typés pour le template ===
// Angular strict templates ne fait pas le narrowing d'une union discriminée à travers
// *ngSwitchCase. Ces helpers castent vers la bonne variante pour rendre les inputs lisibles.
asQuestCompleted(p: Prerequisite): { kind: 'QUEST_COMPLETED'; questId: string } {
return p as { kind: 'QUEST_COMPLETED'; questId: string };
}
asSessionReached(p: Prerequisite): { kind: 'SESSION_REACHED'; minSessionNumber: number } {
return p as { kind: 'SESSION_REACHED'; minSessionNumber: number };
}
asFlagSet(p: Prerequisite): { kind: 'FLAG_SET'; flagName: string } {
return p as { kind: 'FLAG_SET'; flagName: string };
}
}

View File

@@ -0,0 +1,4 @@
<span [class]="cssClass" [attr.aria-label]="label" [title]="label">
<lucide-icon [img]="icon" [size]="14"></lucide-icon>
<span class="status-label" *ngIf="!compact">{{ label }}</span>
</span>

View File

@@ -0,0 +1,43 @@
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
border: 1px solid transparent;
}
.status-label {
white-space: nowrap;
}
// Verrouillée — gris discret
.status-locked {
background: rgba(128, 128, 128, 0.12);
color: #6b6b6b;
border-color: rgba(128, 128, 128, 0.25);
}
// Disponible — vert calme (prête à être lancée)
.status-available {
background: rgba(52, 168, 83, 0.12);
color: #2f7a47;
border-color: rgba(52, 168, 83, 0.3);
}
// En cours — bleu actif
.status-in_progress {
background: rgba(66, 133, 244, 0.14);
color: #2c6cd6;
border-color: rgba(66, 133, 244, 0.35);
}
// Terminée — violet doux (clos, pas neutre)
.status-completed {
background: rgba(120, 80, 200, 0.12);
color: #6d4fb5;
border-color: rgba(120, 80, 200, 0.3);
}

View File

@@ -0,0 +1,46 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Lock, Circle, Play, CheckCircle2, LucideIconData } from 'lucide-angular';
import { QuestStatus } from '../../services/campaign.model';
/**
* Badge visuel pour un QuestStatus (vue Hub).
* Composant standalone, sans dépendance métier.
*/
@Component({
selector: 'app-quest-status-badge',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './quest-status-badge.component.html',
styleUrls: ['./quest-status-badge.component.scss']
})
export class QuestStatusBadgeComponent {
@Input() status: QuestStatus | undefined | null = 'AVAILABLE';
/** Variante visuelle compacte (sans label) — utile pour les listes denses. */
@Input() compact = false;
get icon(): LucideIconData {
switch (this.status) {
case 'LOCKED': return Lock;
case 'IN_PROGRESS': return Play;
case 'COMPLETED': return CheckCircle2;
case 'AVAILABLE':
default: return Circle;
}
}
get label(): string {
switch (this.status) {
case 'LOCKED': return 'Verrouillée';
case 'IN_PROGRESS': return 'En cours';
case 'COMPLETED': return 'Terminée';
case 'AVAILABLE':
default: return 'Disponible';
}
}
get cssClass(): string {
return `status-badge status-${(this.status ?? 'AVAILABLE').toLowerCase()}`;
}
}

View File

@@ -0,0 +1,126 @@
<div class="rooms-editor">
<p class="rooms-empty" *ngIf="rooms.length === 0">
Aucune pièce. La scène se comporte comme un beat narratif classique.
Ajoutez une première pièce pour la convertir en lieu explorable (donjon, crypte…).
</p>
<ul class="room-list" *ngIf="rooms.length > 0">
<li class="room-card" *ngFor="let r of rooms; let i = index; trackBy: trackById">
<!-- Header repliable -->
<header class="room-head" (click)="toggleExpanded(r.id)">
<button type="button" class="room-toggle" type="button">
<lucide-icon [img]="isExpanded(r.id) ? ChevronUp : ChevronDown" [size]="16"></lucide-icon>
</button>
<span class="room-index">#{{ i + 1 }}</span>
<input type="text" class="room-name-input"
[ngModel]="r.name"
(ngModelChange)="patch(r, { name: $event })"
(click)="$event.stopPropagation()"
placeholder="Nom de la pièce" />
<input type="number" class="room-floor-input"
[ngModel]="r.floor"
(ngModelChange)="patch(r, { floor: $event === null || $event === '' ? null : +$event })"
(click)="$event.stopPropagation()"
placeholder="Ét." title="Étage (0 = RdC)" />
<div class="room-actions" (click)="$event.stopPropagation()">
<button type="button" class="btn-icon" (click)="moveUp(r)" [disabled]="i === 0" title="Monter"></button>
<button type="button" class="btn-icon" (click)="moveDown(r)" [disabled]="i === rooms.length - 1" title="Descendre"></button>
<button type="button" class="btn-icon-danger" (click)="removeRoom(r)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
</header>
<!-- Corps déplié -->
<div class="room-body" *ngIf="isExpanded(r.id)">
<div class="field">
<label>Description (lue/résumée aux joueurs)</label>
<textarea rows="3"
[ngModel]="r.description"
(ngModelChange)="patch(r, { description: $event })"
placeholder="Atmosphère, ce que voient les PJ en entrant…"></textarea>
</div>
<div class="field-row">
<div class="field">
<label>Ennemis / créatures / boss</label>
<textarea rows="3"
[ngModel]="r.enemies"
(ngModelChange)="patch(r, { enemies: $event })"
placeholder="2 gobelins, 1 ogre boss (PV 60, AC 14)…"></textarea>
</div>
<div class="field">
<label>Loot / trésors</label>
<textarea rows="3"
[ngModel]="r.loot"
(ngModelChange)="patch(r, { loot: $event })"
placeholder="50 po, potion de soin, clé en argent…"></textarea>
</div>
</div>
<div class="field-row">
<div class="field">
<label>Pièges / dangers</label>
<textarea rows="2"
[ngModel]="r.traps"
(ngModelChange)="patch(r, { traps: $event })"
placeholder="Trappe (DC 15 Perception), flèches empoisonnées…"></textarea>
</div>
<div class="field">
<label>Notes MJ (privé)</label>
<textarea rows="2"
[ngModel]="r.gmNotes"
(ngModelChange)="patch(r, { gmNotes: $event })"
placeholder="Le boss connaît les PJ par leur nom, indice…"></textarea>
</div>
</div>
<!-- Branches inter-pièces -->
<div class="field">
<label>
<lucide-icon [img]="GitBranch" [size]="14"></lucide-icon>
Sorties / portes vers d'autres pièces
</label>
<ul class="branch-list" *ngIf="(r.branches?.length ?? 0) > 0">
<li class="branch-row" *ngFor="let b of r.branches; let bi = index">
<input type="text" class="branch-label"
[ngModel]="b.label"
(ngModelChange)="updateBranch(r, bi, { label: $event })"
placeholder="Porte nord" />
<span class="branch-arrow"></span>
<select class="branch-target"
[ngModel]="b.targetRoomId"
(ngModelChange)="updateBranch(r, bi, { targetRoomId: $event })"
[disabled]="otherRooms(r).length === 0">
<option *ngIf="otherRooms(r).length === 0" value="">(aucune autre pièce)</option>
<option *ngFor="let o of otherRooms(r)" [value]="o.id">{{ o.name }}</option>
</select>
<input type="text" class="branch-condition"
[ngModel]="b.condition"
(ngModelChange)="updateBranch(r, bi, { condition: $event })"
placeholder="Condition (optionnelle)" />
<button type="button" class="btn-icon-danger" (click)="removeBranch(r, bi)">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</li>
</ul>
<button type="button" class="btn-secondary btn-small"
(click)="addBranch(r)"
[disabled]="otherRooms(r).length === 0">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
Ajouter une sortie
</button>
</div>
</div>
</li>
</ul>
<button type="button" class="btn-primary" (click)="addRoom()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Ajouter une pièce
</button>
</div>

View File

@@ -0,0 +1,159 @@
.rooms-editor { display: flex; flex-direction: column; gap: 0.75rem; }
.rooms-empty {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted, #777);
font-style: italic;
}
.room-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.room-card {
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
background: var(--color-surface, #fff);
overflow: hidden;
}
.room-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.7rem;
cursor: pointer;
background: rgba(66, 133, 244, 0.04);
&:hover { background: rgba(66, 133, 244, 0.07); }
}
.room-toggle {
background: transparent;
border: none;
display: inline-flex;
align-items: center;
color: var(--color-text-muted, #666);
cursor: pointer;
padding: 0.25rem;
}
.room-index {
font-size: 0.78rem;
color: var(--color-text-muted, #777);
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.room-name-input {
flex: 1;
padding: 0.35rem 0.55rem;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
&:hover, &:focus {
background: var(--color-bg, #fafafa);
border-color: var(--color-border, #d8d8d8);
outline: none;
}
}
.room-floor-input {
width: 56px;
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border, #d8d8d8);
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
}
.room-actions {
display: flex;
gap: 0.25rem;
}
.btn-icon, .btn-icon-danger {
background: transparent;
border: none;
padding: 0.3rem 0.45rem;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
font-size: 0.85rem;
&:hover { background: rgba(0,0,0,0.05); }
&:disabled { opacity: 0.35; cursor: not-allowed; }
}
.btn-icon-danger {
color: var(--color-danger, #c0392b);
&:hover { background: rgba(192, 57, 43, 0.08); }
}
.room-body {
padding: 0.85rem 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.7rem;
border-top: 1px solid var(--color-border, #eaeaea);
}
.field { display: flex; flex-direction: column; gap: 0.3rem; }
.field label {
font-size: 0.82rem;
color: var(--color-text-muted, #555);
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.field textarea, .field input[type="text"] {
padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border, #d8d8d8);
border-radius: 6px;
font-family: inherit;
font-size: 0.88rem;
resize: vertical;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
}
// Branches
.branch-list {
list-style: none;
padding: 0;
margin: 0 0 0.4rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.branch-row {
display: grid;
grid-template-columns: 1.2fr auto 1.5fr 1.5fr auto;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.45rem;
border: 1px solid var(--color-border, #ececec);
border-radius: 6px;
background: var(--color-bg, #fafafa);
}
.branch-arrow { font-weight: 700; color: var(--color-text-muted, #888); }
.branch-label, .branch-condition, .branch-target {
padding: 0.3rem 0.45rem;
border: 1px solid var(--color-border, #d8d8d8);
border-radius: 6px;
font-size: 0.85rem;
}
.btn-small {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
font-size: 0.82rem;
}

View File

@@ -0,0 +1,133 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ChevronDown, ChevronUp, GitBranch, X } from 'lucide-angular';
import { Room, RoomBranch } from '../../services/campaign.model';
/**
* Éditeur de pièces (Rooms) d'une Scene explorable.
*
* Contrôlé : reçoit la liste, émet la nouvelle liste à chaque mutation.
* Une pièce est repliée par défaut (juste le nom visible) ; un clic l'expand
* pour éditer narration / ennemis / loot / pièges / notes MJ / étage.
*
* Les branches (graphe inter-pièces) sont éditables dans la card expand.
*/
@Component({
selector: 'app-rooms-editor',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './rooms-editor.component.html',
styleUrls: ['./rooms-editor.component.scss']
})
export class RoomsEditorComponent {
@Input() rooms: Room[] = [];
@Output() roomsChange = new EventEmitter<Room[]>();
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
readonly ChevronUp = ChevronUp;
readonly GitBranch = GitBranch;
readonly X = X;
/** Set des IDs de pièces expandées (UI state local). */
private expanded = new Set<string>();
isExpanded(id: string): boolean { return this.expanded.has(id); }
toggleExpanded(id: string): void {
if (this.expanded.has(id)) this.expanded.delete(id);
else this.expanded.add(id);
}
/** Génère un UUID v4 simplifié pour l'ID stable d'une pièce. */
private newId(): string {
return 'room-' + Math.random().toString(36).slice(2) + '-' + Date.now().toString(36);
}
addRoom(): void {
const nextOrder = this.rooms.length > 0
? Math.max(...this.rooms.map(r => r.order ?? 0)) + 1
: 0;
const newRoom: Room = {
id: this.newId(),
name: `Salle ${this.rooms.length + 1}`,
order: nextOrder,
branches: []
};
this.rooms = [...this.rooms, newRoom];
this.expanded.add(newRoom.id);
this.emit();
}
removeRoom(room: Room): void {
this.rooms = this.rooms
.filter(r => r.id !== room.id)
.map(r => ({
...r,
// Nettoyer les branches qui pointaient vers la pièce supprimée
branches: (r.branches ?? []).filter(b => b.targetRoomId !== room.id)
}));
this.expanded.delete(room.id);
this.emit();
}
/** Patch un champ d'une pièce et émet. */
patch(room: Room, patch: Partial<Room>): void {
this.rooms = this.rooms.map(r => r.id === room.id ? { ...r, ...patch } : r);
this.emit();
}
// === Branches inter-pièces ===
addBranch(room: Room): void {
const others = this.rooms.filter(r => r.id !== room.id);
const branch: RoomBranch = {
label: 'Porte',
targetRoomId: others[0]?.id ?? '',
condition: ''
};
this.patch(room, { branches: [...(room.branches ?? []), branch] });
}
removeBranch(room: Room, index: number): void {
const next = (room.branches ?? []).filter((_, i) => i !== index);
this.patch(room, { branches: next });
}
updateBranch(room: Room, index: number, patch: Partial<RoomBranch>): void {
const next = (room.branches ?? []).map((b, i) => i === index ? { ...b, ...patch } : b);
this.patch(room, { branches: next });
}
/** Pièces candidates pour cibler une branche (toutes sauf celle d'origine). */
otherRooms(room: Room): Room[] {
return this.rooms.filter(r => r.id !== room.id);
}
/** Réorganise l'ordre via boutons up/down (simple : swap avec voisin). */
moveUp(room: Room): void {
const idx = this.rooms.findIndex(r => r.id === room.id);
if (idx <= 0) return;
const arr = [...this.rooms];
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
this.rooms = arr.map((r, i) => ({ ...r, order: i }));
this.emit();
}
moveDown(room: Room): void {
const idx = this.rooms.findIndex(r => r.id === room.id);
if (idx === -1 || idx >= this.rooms.length - 1) return;
const arr = [...this.rooms];
[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
this.rooms = arr.map((r, i) => ({ ...r, order: i }));
this.emit();
}
trackById(_: number, r: Room): string { return r.id; }
private emit(): void {
this.roomsChange.emit(this.rooms);
}
}