From b2c3800bf80d514d2cd1a4066fefbb026a5a286a Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Fri, 3 Jul 2026 15:29:23 +0200 Subject: [PATCH] =?UTF-8?q?Co-MJ=20v1=20:=20qu=C3=AAtes=20de=20premi=C3=A8?= =?UTF-8?q?re=20classe,=20guidage=20de=20pr=C3=A9paration,=20assistant=20I?= =?UTF-8?q?A,=20mode=20s=C3=A9ance,=20battlemaps=20multiples=20et=20export?= =?UTF-8?q?=20Foundry=20cibl=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10). Fusion quête/conteneur dans la sidebar, progression par partie (statuts disponible/en cours/terminée), et quêtes libres avec espace de scènes créé automatiquement (arc technique « Quêtes libres », V21 : levée de la contrainte arcs.type héritée du baseline). - Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/ scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et pastilles détaillées (tooltip des manques) dans l'arbre. - Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et brouillons de scènes en propose→applique (brain : narrative-fields, scene-drafts). - Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts. - Mode séance : préparation de séance (readiness + quêtes dispo), scène épinglée (V19), récap « précédemment » (brain : session-recap), onglet « Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de liens). - Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en 1 requête au lieu de ~15. - Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec légendes, export/import rétro-compatible. - Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux / tables), bundle filtré côté serveur (zip allégé), module Foundry à jour (respect du périmètre + une Scene Foundry par variante de carte, rétro-compatible anciens bundles). --- brain/app/api/routers/generation.py | 159 ++++++ .../application/prompts/narrative_fields.py | 50 ++ brain/app/application/prompts/scene_drafts.py | 30 ++ .../app/application/prompts/session_recap.py | 25 + .../campaigncontext/ArcService.java | 45 +- .../CampaignReadinessAssessment.java | 23 + .../CampaignReadinessService.java | 320 +++++++++++ .../CampaignReferencedFlagsService.java | 34 +- .../campaigncontext/ChapterService.java | 30 ++ .../ChapterStatusEnricher.java | 104 ---- .../campaigncontext/QuestService.java | 219 ++++++++ .../campaigncontext/QuestStatusEnricher.java | 122 +++++ .../campaigncontext/ReadinessGap.java | 34 ++ .../campaigncontext/SceneService.java | 91 ++++ .../generationcontext/DraftScenesUseCase.java | 105 ++++ .../NarrativeAssistFieldsUseCase.java | 112 ++++ .../NarrativeFieldCatalog.java | 126 +++++ .../SessionStructuralContextBuilder.java | 76 +-- .../application/playcontext/ClockService.java | 141 +++++ .../application/playcontext/FrontService.java | 75 +++ .../playcontext/SessionPrepReport.java | 52 ++ .../playcontext/SessionPrepService.java | 204 +++++++ .../playcontext/SessionRecapService.java | 85 +++ .../playcontext/SessionService.java | 20 +- .../domain/campaigncontext/ArcType.java | 11 +- .../domain/campaigncontext/Chapter.java | 8 - .../EntityFieldPatchProposal.java | 21 + .../domain/campaigncontext/FieldProposal.java | 16 + .../domain/campaigncontext/LinkType.java | 17 + .../domain/campaigncontext/NodeType.java | 14 + .../domain/campaigncontext/Quest.java | 67 +++ .../domain/campaigncontext/QuestNodeRef.java | 17 + .../campaigncontext/ReadinessEntityType.java | 16 + .../campaigncontext/ReadinessSeverity.java | 19 + .../campaigncontext/ReadinessStatus.java | 22 + .../domain/campaigncontext/Scene.java | 31 +- .../campaigncontext/SceneBattlemap.java | 23 + .../domain/campaigncontext/SceneBranch.java | 13 +- .../domain/campaigncontext/SceneDraft.java | 13 + .../campaigncontext/SceneDraftProposal.java | 14 + .../domain/campaigncontext/SceneType.java | 23 + .../ports/NarrativeAssistException.java | 16 + .../ports/NarrativeFieldAssistant.java | 32 ++ .../ports/QuestRepository.java | 27 + .../ports/SceneDraftAssistant.java | 22 + .../loremind/domain/playcontext/Clock.java | 55 ++ .../domain/playcontext/ClockTrigger.java | 17 + .../loremind/domain/playcontext/Front.java | 31 ++ .../domain/playcontext/QuestProgression.java | 4 +- .../loremind/domain/playcontext/Session.java | 6 + .../playcontext/ports/ClockRepository.java | 23 + .../playcontext/ports/FrontRepository.java | 23 + .../ports/QuestProgressionRepository.java | 9 +- .../ports/SessionRecapAssistant.java | 18 + .../ports/SessionRecapException.java | 16 + .../ai/BrainNarrativeFieldClient.java | 95 ++++ .../ai/BrainSceneDraftClient.java | 91 ++++ .../ai/BrainSessionRecapClient.java | 64 +++ .../converter/QuestNodeListJsonConverter.java | 67 +++ .../SceneBattlemapListJsonConverter.java | 45 ++ .../persistence/entity/ChapterJpaEntity.java | 12 - .../persistence/entity/ClockJpaEntity.java | 76 +++ .../persistence/entity/FrontJpaEntity.java | 56 ++ .../persistence/entity/QuestJpaEntity.java | 101 ++++ .../entity/QuestProgressionJpaEntity.java | 12 +- .../persistence/entity/SceneJpaEntity.java | 29 +- .../persistence/entity/SessionJpaEntity.java | 4 + .../persistence/jpa/ClockJpaRepository.java | 16 + .../persistence/jpa/FrontJpaRepository.java | 16 + .../persistence/jpa/QuestJpaRepository.java | 18 + .../jpa/QuestProgressionJpaRepository.java | 7 +- .../migration/PlaythroughMigrationRunner.java | 12 +- .../postgres/PostgresChapterRepository.java | 6 - .../postgres/PostgresClockRepository.java | 87 +++ .../postgres/PostgresFrontRepository.java | 76 +++ .../PostgresQuestProgressionRepository.java | 21 +- .../postgres/PostgresQuestRepository.java | 129 +++++ .../postgres/PostgresSceneRepository.java | 17 +- .../postgres/PostgresSessionRepository.java | 3 + .../transfer/ExportService.java | 74 ++- .../infrastructure/transfer/IdRemapper.java | 25 +- .../transfer/ImportService.java | 190 ++++++- .../transfer/dto/ContentExport.java | 70 ++- .../transfer/foundry/FoundryBundle.java | 16 + .../foundry/FoundryExportService.java | 121 +++-- .../transfer/pdf/PdfExportService.java | 133 ++++- .../CampaignReadinessController.java | 39 ++ .../controller/CampaignTreeController.java | 148 +++++ .../web/controller/ChapterController.java | 32 +- .../web/controller/ClockController.java | 88 +++ .../controller/FoundryExportController.java | 14 +- .../web/controller/FrontController.java | 57 ++ .../controller/NarrativeAssistController.java | 87 +++ .../controller/PlaythroughFlagController.java | 8 +- .../web/controller/QuestController.java | 98 ++++ .../QuestProgressionController.java | 20 +- .../web/controller/SceneDraftController.java | 80 +++ .../web/controller/SessionController.java | 30 +- .../web/controller/SessionPrepController.java | 37 ++ .../web/dto/campaigncontext/ChapterDTO.java | 16 - .../web/dto/campaigncontext/QuestDTO.java | 51 ++ .../dto/campaigncontext/QuestNodeRefDTO.java | 24 + .../campaigncontext/SceneBattlemapDTO.java | 24 + .../dto/campaigncontext/SceneBranchDTO.java | 3 + .../web/dto/campaigncontext/SceneDTO.java | 12 +- .../web/dto/playcontext/ClockDTO.java | 30 ++ .../web/dto/playcontext/FrontDTO.java | 20 + .../web/dto/playcontext/SessionDTO.java | 2 + .../web/mapper/ChapterMapper.java | 15 +- .../web/mapper/ClockMapper.java | 32 ++ .../web/mapper/FrontMapper.java | 25 + .../web/mapper/QuestMapper.java | 92 ++++ .../web/mapper/SceneMapper.java | 56 +- .../web/mapper/SessionMapper.java | 1 + .../V10__Backfill_quests_from_chapters.java | 69 +++ .../V21__Drop_arc_type_check_constraint.java | 48 ++ .../V11__quest_progression_to_quest_id.sql | 16 + .../V12__drop_chapter_prerequisites.sql | 5 + .../db/migration/V13__add_scene_type.sql | 6 + .../V14__add_scene_graph_position.sql | 5 + .../db/migration/V15__create_clocks.sql | 16 + .../db/migration/V16__add_clock_trigger.sql | 5 + .../db/migration/V17__create_fronts.sql | 16 + .../migration/V18__add_arc_id_to_quests.sql | 6 + .../migration/V19__session_current_scene.sql | 4 + .../V20__attach_legacy_hub_quests.sql | 20 + .../migration/V22__scene_battlemaps_list.sql | 23 + .../db/migration/V9__create_quests.sql | 22 + .../campaigncontext/ArcServiceTest.java | 30 ++ .../CampaignReadinessServiceTest.java | 404 ++++++++++++++ .../CampaignReferencedFlagsServiceTest.java | 55 +- .../campaigncontext/ChapterServiceTest.java | 27 + .../campaigncontext/QuestServiceTest.java | 256 +++++++++ ...Test.java => QuestStatusEnricherTest.java} | 134 ++--- .../campaigncontext/SceneServiceTest.java | 121 +++++ .../playcontext/ClockServiceTest.java | 128 +++++ .../playcontext/SessionPrepServiceTest.java | 220 ++++++++ .../playcontext/SessionRecapServiceTest.java | 92 ++++ .../playcontext/SessionServiceTest.java | 63 +++ .../domain/campaigncontext/SceneTest.java | 26 +- .../persistence/FlywayMigrationTest.java | 63 ++- ...throughMigrationRunnerProgressionTest.java | 79 +++ .../migration/QuestBackfillMigrationTest.java | 183 +++++++ .../postgres/PostgresSceneRepositoryTest.java | 12 +- .../ContentExportImportRoundTripTest.java | 71 ++- .../transfer/ContentExportV1ImportTest.java | 70 +++ .../foundry/FoundryExportServiceTest.java | 64 ++- .../pdf/PdfExportServiceRichTest.java | 42 +- .../CampaignFlagControllerTest.java | 27 +- .../CampaignTreeControllerTest.java | 77 +++ .../web/controller/ChapterControllerTest.java | 26 - .../DataTransferControllerTest.java | 3 +- .../web/controller/QuestControllerTest.java | 152 ++++++ web/angular.json | 4 +- web/e2e/tests/campaign/arc-edit.spec.ts | 8 + web/e2e/tests/campaign/chapter-edit.spec.ts | 6 + web/src/app/app.routes.ts | 4 + .../arc/arc-edit/arc-edit.component.html | 125 +++-- .../arc/arc-edit/arc-edit.component.scss | 11 + .../arc/arc-edit/arc-edit.component.ts | 28 +- .../arc/arc-view/arc-view.component.html | 75 +-- .../arc/arc-view/arc-view.component.ts | 77 +-- .../campaigns/campaign-tree.helper.spec.ts | 111 +++- web/src/app/campaigns/campaign-tree.helper.ts | 365 +++++++++---- .../campaign-detail.component.html | 33 +- .../campaign-detail.component.scss | 13 + .../campaign-detail.component.ts | 91 +++- .../foundry-export-dialog.component.html | 59 ++ .../foundry-export-dialog.component.scss | 151 ++++++ .../foundry-export-dialog.component.ts | 54 ++ .../chapter-create.component.html | 12 +- .../chapter-create.component.ts | 3 - .../chapter-edit/chapter-edit.component.html | 111 ++-- .../chapter-edit/chapter-edit.component.scss | 11 + .../chapter-edit/chapter-edit.component.ts | 72 +-- .../chapter-graph.component.html | 135 ++++- .../chapter-graph.component.scss | 235 +++++++- .../chapter-graph/chapter-graph.component.ts | 510 +++++++++++++++--- .../chapter-view/chapter-view.component.html | 32 +- .../chapter-view/chapter-view.component.ts | 27 +- web/src/app/campaigns/gap-action.helper.ts | 54 ++ .../notebook-detail.component.ts | 6 +- .../playthrough-detail.component.html | 10 + .../playthrough-detail.component.ts | 4 +- .../quest-edit/quest-edit.component.html | 141 +++++ .../quest-edit/quest-edit.component.scss | 40 ++ .../quest/quest-edit/quest-edit.component.ts | 235 ++++++++ .../quest-list/quest-list.component.html | 39 ++ .../quest-list/quest-list.component.scss | 84 +++ .../quest/quest-list/quest-list.component.ts | 89 +++ .../quest-view/quest-view.component.html | 117 ++++ .../quest-view/quest-view.component.scss | 5 + .../quest/quest-view/quest-view.component.ts | 204 +++++++ .../random-table-view.component.ts | 31 +- .../scene-edit/scene-edit.component.html | 236 ++++---- .../scene-edit/scene-edit.component.scss | 70 +++ .../scene/scene-edit/scene-edit.component.ts | 321 +++++++---- .../app/services/campaign-sidebar.service.ts | 5 +- web/src/app/services/campaign.model.ts | 126 ++++- web/src/app/services/campaign.service.ts | 75 ++- web/src/app/services/clock.model.ts | 32 ++ web/src/app/services/clock.service.ts | 42 ++ web/src/app/services/entity-assist.model.ts | 18 + web/src/app/services/entity-assist.service.ts | 27 + web/src/app/services/front.model.ts | 15 + web/src/app/services/front.service.ts | 34 ++ web/src/app/services/layout.service.ts | 10 + web/src/app/services/quest.service.ts | 55 ++ web/src/app/services/readiness.model.ts | 30 ++ web/src/app/services/readiness.service.ts | 18 + web/src/app/services/scene-draft.model.ts | 15 + web/src/app/services/scene-draft.service.ts | 26 + web/src/app/services/session-prep.model.ts | 48 ++ web/src/app/services/session-prep.service.ts | 18 + web/src/app/services/session.model.ts | 2 + web/src/app/services/session.service.ts | 10 + .../session-detail.component.html | 67 ++- .../session-detail.component.scss | 89 +++ .../session-detail.component.ts | 107 +++- .../session-reference-panel.component.html | 47 +- .../session-reference-panel.component.scss | 41 ++ .../session-reference-panel.component.ts | 15 +- .../clocks-manager.component.html | 158 ++++++ .../clocks-manager.component.scss | 175 ++++++ .../clocks-manager.component.ts | 245 +++++++++ .../enemy-link-picker.component.html | 17 + .../enemy-link-picker.component.scss | 13 + .../enemy-link-picker.component.ts | 22 +- .../entity-assist-panel.component.html | 60 +++ .../entity-assist-panel.component.scss | 147 +++++ .../entity-assist-panel.component.ts | 113 ++++ .../expandable-section.component.html | 3 + .../expandable-section.component.scss | 13 + .../expandable-section.component.ts | 6 + .../node-picker/node-picker.component.html | 51 ++ .../node-picker/node-picker.component.scss | 68 +++ .../node-picker/node-picker.component.ts | 101 ++++ .../prerequisite-editor.component.ts | 12 +- .../readiness-panel.component.html | 50 ++ .../readiness-panel.component.scss | 135 +++++ .../readiness-panel.component.ts | 83 +++ .../scene-draft-panel.component.html | 60 +++ .../scene-draft-panel.component.scss | 145 +++++ .../scene-draft-panel.component.ts | 119 ++++ .../secondary-sidebar.component.ts | 2 +- .../sidebar-tree-node.component.html | 7 + .../sidebar-tree-node.component.scss | 12 + .../session-prep-panel.component.html | 130 +++++ .../session-prep-panel.component.scss | 180 +++++++ .../session-prep-panel.component.ts | 107 ++++ web/src/assets/i18n/en.json | 340 +++++++++++- web/src/assets/i18n/fr.json | 340 +++++++++++- web/src/assets/i18n/fragments/arcs.en.json | 7 + web/src/assets/i18n/fragments/arcs.fr.json | 7 + .../i18n/fragments/campaign-tree.en.json | 2 + .../i18n/fragments/campaign-tree.fr.json | 2 + .../i18n/fragments/campaigns-top.en.json | 19 + .../i18n/fragments/campaigns-top.fr.json | 19 + .../assets/i18n/fragments/chapters.en.json | 31 +- .../assets/i18n/fragments/chapters.fr.json | 31 +- web/src/assets/i18n/fragments/clocks.en.json | 35 ++ web/src/assets/i18n/fragments/clocks.fr.json | 35 ++ .../i18n/fragments/entity-assist.en.json | 33 ++ .../i18n/fragments/entity-assist.fr.json | 33 ++ web/src/assets/i18n/fragments/quests.en.json | 76 +++ web/src/assets/i18n/fragments/quests.fr.json | 76 +++ .../assets/i18n/fragments/readiness.en.json | 19 + .../assets/i18n/fragments/readiness.fr.json | 19 + .../assets/i18n/fragments/scene-draft.en.json | 16 + .../assets/i18n/fragments/scene-draft.fr.json | 16 + web/src/assets/i18n/fragments/scenes.en.json | 27 +- web/src/assets/i18n/fragments/scenes.fr.json | 27 +- .../i18n/fragments/session-cockpit.en.json | 25 + .../i18n/fragments/session-cockpit.fr.json | 25 + .../i18n/fragments/session-prep.en.json | 28 + .../i18n/fragments/session-prep.fr.json | 28 + .../i18n/fragments/shared-widgets-a.en.json | 4 +- .../i18n/fragments/shared-widgets-a.fr.json | 4 +- 278 files changed, 15570 insertions(+), 1451 deletions(-) create mode 100644 brain/app/application/prompts/narrative_fields.py create mode 100644 brain/app/application/prompts/scene_drafts.py create mode 100644 brain/app/application/prompts/session_recap.py create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/CampaignReadinessAssessment.java create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/CampaignReadinessService.java delete mode 100644 core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/QuestService.java create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/QuestStatusEnricher.java create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/ReadinessGap.java create mode 100644 core/src/main/java/com/loremind/application/generationcontext/DraftScenesUseCase.java create mode 100644 core/src/main/java/com/loremind/application/generationcontext/NarrativeAssistFieldsUseCase.java create mode 100644 core/src/main/java/com/loremind/application/generationcontext/NarrativeFieldCatalog.java create mode 100644 core/src/main/java/com/loremind/application/playcontext/ClockService.java create mode 100644 core/src/main/java/com/loremind/application/playcontext/FrontService.java create mode 100644 core/src/main/java/com/loremind/application/playcontext/SessionPrepReport.java create mode 100644 core/src/main/java/com/loremind/application/playcontext/SessionPrepService.java create mode 100644 core/src/main/java/com/loremind/application/playcontext/SessionRecapService.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/EntityFieldPatchProposal.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/FieldProposal.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/LinkType.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/NodeType.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/Quest.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/QuestNodeRef.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ReadinessEntityType.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ReadinessSeverity.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ReadinessStatus.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/SceneBattlemap.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/SceneDraft.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/SceneDraftProposal.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/SceneType.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/NarrativeAssistException.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/NarrativeFieldAssistant.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/QuestRepository.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/SceneDraftAssistant.java create mode 100644 core/src/main/java/com/loremind/domain/playcontext/Clock.java create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ClockTrigger.java create mode 100644 core/src/main/java/com/loremind/domain/playcontext/Front.java create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/ClockRepository.java create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/FrontRepository.java create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/SessionRecapAssistant.java create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/SessionRecapException.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainNarrativeFieldClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainSceneDraftClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainSessionRecapClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/converter/QuestNodeListJsonConverter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/converter/SceneBattlemapListJsonConverter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/ClockJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/FrontJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/ClockJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/FrontJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresClockRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresFrontRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/CampaignReadinessController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/CampaignTreeController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/ClockController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/FrontController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/NarrativeAssistController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/SceneDraftController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/SessionPrepController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/QuestDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/QuestNodeRefDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBattlemapDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/ClockDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/FrontDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/ClockMapper.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/FrontMapper.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/QuestMapper.java create mode 100644 core/src/main/java/db/migration/V10__Backfill_quests_from_chapters.java create mode 100644 core/src/main/java/db/migration/V21__Drop_arc_type_check_constraint.java create mode 100644 core/src/main/resources/db/migration/V11__quest_progression_to_quest_id.sql create mode 100644 core/src/main/resources/db/migration/V12__drop_chapter_prerequisites.sql create mode 100644 core/src/main/resources/db/migration/V13__add_scene_type.sql create mode 100644 core/src/main/resources/db/migration/V14__add_scene_graph_position.sql create mode 100644 core/src/main/resources/db/migration/V15__create_clocks.sql create mode 100644 core/src/main/resources/db/migration/V16__add_clock_trigger.sql create mode 100644 core/src/main/resources/db/migration/V17__create_fronts.sql create mode 100644 core/src/main/resources/db/migration/V18__add_arc_id_to_quests.sql create mode 100644 core/src/main/resources/db/migration/V19__session_current_scene.sql create mode 100644 core/src/main/resources/db/migration/V20__attach_legacy_hub_quests.sql create mode 100644 core/src/main/resources/db/migration/V22__scene_battlemaps_list.sql create mode 100644 core/src/main/resources/db/migration/V9__create_quests.sql create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/CampaignReadinessServiceTest.java create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java rename core/src/test/java/com/loremind/application/campaigncontext/{ChapterStatusEnricherTest.java => QuestStatusEnricherTest.java} (56%) create mode 100644 core/src/test/java/com/loremind/application/playcontext/ClockServiceTest.java create mode 100644 core/src/test/java/com/loremind/application/playcontext/SessionPrepServiceTest.java create mode 100644 core/src/test/java/com/loremind/application/playcontext/SessionRecapServiceTest.java create mode 100644 core/src/test/java/com/loremind/application/playcontext/SessionServiceTest.java create mode 100644 core/src/test/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunnerProgressionTest.java create mode 100644 core/src/test/java/com/loremind/infrastructure/persistence/migration/QuestBackfillMigrationTest.java create mode 100644 core/src/test/java/com/loremind/infrastructure/web/controller/CampaignTreeControllerTest.java create mode 100644 core/src/test/java/com/loremind/infrastructure/web/controller/QuestControllerTest.java create mode 100644 web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.html create mode 100644 web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.scss create mode 100644 web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.ts create mode 100644 web/src/app/campaigns/gap-action.helper.ts create mode 100644 web/src/app/campaigns/quest/quest-edit/quest-edit.component.html create mode 100644 web/src/app/campaigns/quest/quest-edit/quest-edit.component.scss create mode 100644 web/src/app/campaigns/quest/quest-edit/quest-edit.component.ts create mode 100644 web/src/app/campaigns/quest/quest-list/quest-list.component.html create mode 100644 web/src/app/campaigns/quest/quest-list/quest-list.component.scss create mode 100644 web/src/app/campaigns/quest/quest-list/quest-list.component.ts create mode 100644 web/src/app/campaigns/quest/quest-view/quest-view.component.html create mode 100644 web/src/app/campaigns/quest/quest-view/quest-view.component.scss create mode 100644 web/src/app/campaigns/quest/quest-view/quest-view.component.ts create mode 100644 web/src/app/services/clock.model.ts create mode 100644 web/src/app/services/clock.service.ts create mode 100644 web/src/app/services/entity-assist.model.ts create mode 100644 web/src/app/services/entity-assist.service.ts create mode 100644 web/src/app/services/front.model.ts create mode 100644 web/src/app/services/front.service.ts create mode 100644 web/src/app/services/quest.service.ts create mode 100644 web/src/app/services/readiness.model.ts create mode 100644 web/src/app/services/readiness.service.ts create mode 100644 web/src/app/services/scene-draft.model.ts create mode 100644 web/src/app/services/scene-draft.service.ts create mode 100644 web/src/app/services/session-prep.model.ts create mode 100644 web/src/app/services/session-prep.service.ts create mode 100644 web/src/app/shared/clocks-manager/clocks-manager.component.html create mode 100644 web/src/app/shared/clocks-manager/clocks-manager.component.scss create mode 100644 web/src/app/shared/clocks-manager/clocks-manager.component.ts create mode 100644 web/src/app/shared/entity-assist-panel/entity-assist-panel.component.html create mode 100644 web/src/app/shared/entity-assist-panel/entity-assist-panel.component.scss create mode 100644 web/src/app/shared/entity-assist-panel/entity-assist-panel.component.ts create mode 100644 web/src/app/shared/node-picker/node-picker.component.html create mode 100644 web/src/app/shared/node-picker/node-picker.component.scss create mode 100644 web/src/app/shared/node-picker/node-picker.component.ts create mode 100644 web/src/app/shared/readiness-panel/readiness-panel.component.html create mode 100644 web/src/app/shared/readiness-panel/readiness-panel.component.scss create mode 100644 web/src/app/shared/readiness-panel/readiness-panel.component.ts create mode 100644 web/src/app/shared/scene-draft-panel/scene-draft-panel.component.html create mode 100644 web/src/app/shared/scene-draft-panel/scene-draft-panel.component.scss create mode 100644 web/src/app/shared/scene-draft-panel/scene-draft-panel.component.ts create mode 100644 web/src/app/shared/session-prep-panel/session-prep-panel.component.html create mode 100644 web/src/app/shared/session-prep-panel/session-prep-panel.component.scss create mode 100644 web/src/app/shared/session-prep-panel/session-prep-panel.component.ts create mode 100644 web/src/assets/i18n/fragments/clocks.en.json create mode 100644 web/src/assets/i18n/fragments/clocks.fr.json create mode 100644 web/src/assets/i18n/fragments/entity-assist.en.json create mode 100644 web/src/assets/i18n/fragments/entity-assist.fr.json create mode 100644 web/src/assets/i18n/fragments/quests.en.json create mode 100644 web/src/assets/i18n/fragments/quests.fr.json create mode 100644 web/src/assets/i18n/fragments/readiness.en.json create mode 100644 web/src/assets/i18n/fragments/readiness.fr.json create mode 100644 web/src/assets/i18n/fragments/scene-draft.en.json create mode 100644 web/src/assets/i18n/fragments/scene-draft.fr.json create mode 100644 web/src/assets/i18n/fragments/session-cockpit.en.json create mode 100644 web/src/assets/i18n/fragments/session-cockpit.fr.json create mode 100644 web/src/assets/i18n/fragments/session-prep.en.json create mode 100644 web/src/assets/i18n/fragments/session-prep.fr.json diff --git a/brain/app/api/routers/generation.py b/brain/app/api/routers/generation.py index d5df287..c09bc95 100644 --- a/brain/app/api/routers/generation.py +++ b/brain/app/api/routers/generation.py @@ -6,7 +6,12 @@ from pydantic import BaseModel, Field from app.api.deps import get_generate_page_use_case, get_llm_provider from app.application.generate_page import GeneratePageUseCase +from app.application.llm_json import load_json_object +from app.application.llm_retry import generate_with_retry from app.application.prompts import conversation_title as title_prompts +from app.application.prompts import narrative_fields as narrative_fields_prompts +from app.application.prompts import scene_drafts as scene_drafts_prompts +from app.application.prompts import session_recap as session_recap_prompts from app.core.config import Settings, get_settings from app.core.language import get_user_language from app.domain.models import PageGenerationContext @@ -131,3 +136,157 @@ async def summarize_conversation_title( if not title: title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"]) return SummarizeTitleResponseDTO(title=title) + + +# --- Étoffer une entité narrative (Pilier A : co-MJ propose → l'humain valide) ---------- + + +class NarrativeFieldSpecDTO(BaseModel): + """Un champ autorisé : clé technique + libellé lisible (fourni par le Core).""" + + key: str + label: str = Field(default="") + + +class NarrativeFieldsRequestDTO(BaseModel): + """Contexte envoyé par le Core pour proposer des valeurs de champs (arc/chapitre/scène).""" + + entity_type: str = Field(default="") + context: str = Field(default="") + instruction: str = Field(default="") + # Whitelist (clé + libellé) fournie par le Core, source de vérité. + fields: list[NarrativeFieldSpecDTO] = Field(default_factory=list) + + +class NarrativeFieldsResponseDTO(BaseModel): + """Retour : une valeur proposée par clé (uniquement des clés autorisées, non vides).""" + + fields: dict[str, str] + + +@router.post("/generate/narrative-fields", response_model=NarrativeFieldsResponseDTO) +async def generate_narrative_fields( + body: NarrativeFieldsRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + language: Annotated[str, Depends(get_user_language)], +) -> NarrativeFieldsResponseDTO: + """Propose des valeurs pour ÉTOFFER une entité narrative (patch champ par champ, non appliqué). + + Whitelist stricte : on ne retient que les clés autorisées, non vides. Un objet vide + est une réponse VALIDE (le modèle n'a rien de pertinent à proposer — l'entité est + peut-être déjà complète) ; seule une sortie non-JSON est une erreur. + """ + allowed = {f.key for f in body.fields if f.key} + prompt = narrative_fields_prompts.narrative_fields_prompt( + body.entity_type, body.context, body.instruction, + [{"key": f.key, "label": f.label} for f in body.fields], language) + try: + raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + parsed, _ = load_json_object(raw) + if not isinstance(parsed, dict): + raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de champs exploitables.") + + out: dict[str, str] = {} + raw_fields = parsed.get("fields") + if isinstance(raw_fields, dict): + for key, value in raw_fields.items(): + if key not in allowed: + continue + if not isinstance(value, (str, int, float)): + continue + text = str(value).strip() + if text: + out[str(key)] = text + return NarrativeFieldsResponseDTO(fields=out) + + +# --- Peupler un chapitre en scènes (Pilier A : capacité « create ») ---------- + + +class SceneDraftsRequestDTO(BaseModel): + """Contexte envoyé par le Core pour ébaucher des scènes d'un chapitre.""" + + context: str = Field(default="") + instruction: str = Field(default="") + count: int = Field(default=4) + + +class SceneDraftDTO(BaseModel): + name: str + description: str = Field(default="") + playerNarration: str = Field(default="") + + +class SceneDraftsResponseDTO(BaseModel): + scenes: list[SceneDraftDTO] + + +@router.post("/generate/scene-drafts", response_model=SceneDraftsResponseDTO) +async def generate_scene_drafts( + body: SceneDraftsRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + language: Annotated[str, Depends(get_user_language)], +) -> SceneDraftsResponseDTO: + """Propose des ébauches de scènes pour un chapitre (non créées). Un titre par scène + est obligatoire ; on borne le nombre. Seule une sortie non-JSON est une erreur.""" + n = max(1, min(8, body.count)) + prompt = scene_drafts_prompts.scene_drafts_prompt(body.context, body.instruction, n, language) + try: + raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.8) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + parsed, _ = load_json_object(raw) + if not isinstance(parsed, dict): + raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de scènes exploitables.") + + scenes: list[SceneDraftDTO] = [] + for s in (parsed.get("scenes") or [])[:n]: + if not isinstance(s, dict): + continue + name = str(s.get("name") or "").strip() + if not name: + continue + scenes.append(SceneDraftDTO( + name=name[:200], + description=str(s.get("description") or "").strip(), + playerNarration=str(s.get("playerNarration") or "").strip(), + )) + return SceneDraftsResponseDTO(scenes=scenes) + + +# --- Récap « précédemment… » d'une séance (mode cockpit) --------------------- + + +class SessionRecapRequestDTO(BaseModel): + """Journal chronologique de la séance précédente + méta courte.""" + + transcript: str + context: str = Field(default="") + + +class SessionRecapResponseDTO(BaseModel): + recap: str + + +@router.post("/generate/session-recap", response_model=SessionRecapResponseDTO) +async def generate_session_recap( + body: SessionRecapRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + language: Annotated[str, Depends(get_user_language)], +) -> SessionRecapResponseDTO: + """Rédige le récap « Précédemment… » à lire aux joueurs (texte libre, pas de JSON).""" + if not body.transcript.strip(): + raise HTTPException(status_code=422, detail="Journal vide : rien à résumer.") + prompt = session_recap_prompts.session_recap_prompt(body.transcript, body.context, language) + try: + raw = await generate_with_retry(llm, prompt, temperature=0.7) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + recap = raw.strip() + if not recap: + raise HTTPException(status_code=502, detail="Le modèle n'a renvoyé aucun récit.") + return SessionRecapResponseDTO(recap=recap) diff --git a/brain/app/application/prompts/narrative_fields.py b/brain/app/application/prompts/narrative_fields.py new file mode 100644 index 0000000..eb5cf63 --- /dev/null +++ b/brain/app/application/prompts/narrative_fields.py @@ -0,0 +1,50 @@ +"""Prompt d'étoffage des champs d'une entité narrative (arc / chapitre / scène) — Pilier A. + +Générique : le Core (Java) est la SOURCE DE VÉRITÉ des champs (clé + libellé) et les +passe en entrée ; ce module ne fait que formuler le prompt. Le modèle ne renvoie que les +clés fournies et OMET celles pour lesquelles il n'a rien de pertinent (pas de remplissage forcé). +""" +from app.core.language import language_name + +# Étiquette lisible du type d'entité, pour la formulation du prompt. +ENTITY_LABEL: dict[str, str] = { + "arc": "cet arc narratif", + "chapter": "ce chapitre", + "scene": "cette scène", +} + + +def narrative_fields_prompt(entity_type: str, entity_context: str, instruction: str, + fields: list[dict], language: str) -> str: + """Construit le prompt d'étoffage. `fields` = [{key, label}] (whitelist du Core).""" + label = ENTITY_LABEL.get(entity_type or "", "cette entité narrative") + lines = [] + for f in fields or []: + key = str(f.get("key") or "").strip() + if not key: + continue + flabel = str(f.get("label") or key).strip() + lines.append(f'- "{key}" : {flabel}') + fields_list = "\n".join(lines) + instruction_block = ( + f"\nConsigne particulière du MJ : {instruction.strip()}\n" + if instruction and instruction.strip() else "" + ) + return ( + f"Tu es un co-Maître de Jeu. On te donne l'état ACTUEL d'{label} de jeu de rôle. " + "Propose des valeurs pour l'ÉTOFFER, cohérentes avec ce qui existe déjà.\n\n" + f"{entity_context.strip()}\n" + f"{instruction_block}\n" + "Champs que tu peux remplir (n'utilise QUE ces clés) :\n" + f"{fields_list}\n\n" + "Règles IMPÉRATIVES :\n" + "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" + '- Format exact : {"fields": {"cle": "valeur proposée", ...}}\n' + "- N'inclus QUE des clés de la liste ci-dessus. N'invente AUCUNE autre clé.\n" + "- Si un champ est déjà bien rempli ou si tu n'as rien de pertinent, OMETS-le " + "(ne le renvoie pas) plutôt que de le remplir de force.\n" + "- Reste cohérent avec le contexte : n'invente pas d'élément qui contredit " + "l'entité ou la campagne.\n" + f"- Rédige les valeurs en {language_name(language)}.\n" + "Renvoie maintenant le JSON." + ) diff --git a/brain/app/application/prompts/scene_drafts.py b/brain/app/application/prompts/scene_drafts.py new file mode 100644 index 0000000..773b8fa --- /dev/null +++ b/brain/app/application/prompts/scene_drafts.py @@ -0,0 +1,30 @@ +"""Prompt d'ébauche de scènes pour un chapitre (Pilier A — capacité « create »). + +Le co-MJ propose plusieurs scènes cohérentes pour PEUPLER un chapitre vide (ou en manque). +JSON structuré, une liste de scènes ; l'humain révise et ne crée que celles qu'il retient. +""" +from app.core.language import language_name + + +def scene_drafts_prompt(context: str, instruction: str, count: int, language: str) -> str: + instruction_block = ( + f"\nConsigne particulière du MJ : {instruction.strip()}\n" + if instruction and instruction.strip() else "" + ) + return ( + f"Tu es un co-Maître de Jeu. Propose {count} SCÈNES de jeu de rôle pour PEUPLER ce " + "chapitre, cohérentes entre elles et avec le contexte.\n\n" + f"{context.strip()}\n" + f"{instruction_block}\n" + "Règles IMPÉRATIVES :\n" + "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" + '- Format exact : {"scenes": [{"name": "...", "description": "...", "playerNarration": "..."}]}\n' + f"- Propose AU PLUS {count} scènes, distinctes et complémentaires (une progression du chapitre).\n" + "- 'name' : titre court et évocateur (OBLIGATOIRE).\n" + "- 'description' : un résumé bref (une phrase).\n" + "- 'playerNarration' : 2-3 phrases de mise en scène lues aux joueurs.\n" + "- Ne DUPLIQUE pas les scènes déjà présentes ; reste cohérent avec le chapitre et la campagne " + "(n'invente pas d'élément qui les contredit).\n" + f"- Rédige en {language_name(language)}.\n" + "Renvoie maintenant le JSON." + ) diff --git a/brain/app/application/prompts/session_recap.py b/brain/app/application/prompts/session_recap.py new file mode 100644 index 0000000..48c22f8 --- /dev/null +++ b/brain/app/application/prompts/session_recap.py @@ -0,0 +1,25 @@ +"""Prompt du récap « précédemment dans… » (mode séance). + +Le Core envoie le journal chronologique de la séance PRÉCÉDENTE ; le modèle rédige un +récapitulatif court, à lire aux joueurs à l'ouverture de la séance suivante. Texte libre +(pas de JSON) : c'est de la narration. +""" +from app.core.language import language_name + + +def session_recap_prompt(transcript: str, context: str, language: str) -> str: + context_block = f"\n{context.strip()}\n" if context and context.strip() else "" + return ( + "Tu es le Maître du Jeu. Voici le journal de la SÉANCE PRÉCÉDENTE de ta table " + "(entrées chronologiques : notes, évènements, jets de dés, actions des joueurs).\n" + f"{context_block}\n" + "Journal :\n" + f"{transcript.strip()}\n\n" + "Rédige un récapitulatif « Précédemment… » à LIRE AUX JOUEURS pour ouvrir la " + "nouvelle séance :\n" + "- 4 à 8 phrases, ton narratif et vivant, au passé.\n" + "- Uniquement ce qui s'est réellement passé dans le journal — n'invente RIEN, " + "ne révèle aucun secret du MJ.\n" + "- Termine sur la situation où les joueurs se sont arrêtés (le « cliffhanger »).\n" + f"- Rédige en {language_name(language)}. Pas de préambule ni de méta : juste le récit." + ) diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java index 75b3354..5152b80 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java @@ -2,9 +2,12 @@ package com.loremind.application.campaigncontext; import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.FieldProposal; +import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; @@ -24,13 +27,16 @@ public class ArcService { private final ArcRepository arcRepository; private final ChapterRepository chapterRepository; private final SceneRepository sceneRepository; + private final QuestRepository questRepository; public ArcService(ArcRepository arcRepository, ChapterRepository chapterRepository, - SceneRepository sceneRepository) { + SceneRepository sceneRepository, + QuestRepository questRepository) { this.arcRepository = arcRepository; this.chapterRepository = chapterRepository; this.sceneRepository = sceneRepository; + this.questRepository = questRepository; } /** Compte des entités qui seront supprimées en cascade avec l'arc. */ @@ -87,6 +93,37 @@ public class ArcService { return arcRepository.save(arc); } + /** + * Patch CIBLÉ champ-par-champ d'un arc (Pilier A — co-création). Applique UNIQUEMENT + * les {@link FieldProposal} reçus ; les autres champs restent INTACTS (contraste voulu + * avec {@link #updateArc} qui écrase tout via BeanUtils). + */ + @Transactional + public Arc patchArc(String id, List fields) { + Arc arc = arcRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Arc non trouvé avec l'ID: " + id)); + if (fields != null) { + for (FieldProposal f : fields) { + if (f == null || f.key() == null) continue; + applyField(arc, f.key(), f.proposedValue()); + } + } + return arcRepository.save(arc); + } + + /** Whitelist STRICTE des champs étoffables d'un arc ; clé inconnue ignorée. */ + private void applyField(Arc arc, String key, String value) { + switch (key) { + case "description" -> arc.setDescription(value); + case "themes" -> arc.setThemes(value); + case "stakes" -> arc.setStakes(value); + case "rewards" -> arc.setRewards(value); + case "resolution" -> arc.setResolution(value); + case "gmNotes" -> arc.setGmNotes(value); + default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ } + } + } + /** * Calcule l'impact d'une suppression en cascade : chapitres + scènes * qui disparaîtront avec l'arc. @@ -112,6 +149,12 @@ public class ArcService { } chapterRepository.deleteById(chapter.getId()); } + // Détache les quêtes rattachées (arc HUB) : elles deviennent TRANSVERSES plutôt + // que fantômes (arcId pointant un arc disparu). Weak ref, pas de FK cascade. + for (Quest quest : questRepository.findByArcId(id)) { + quest.setArcId(null); + questRepository.save(quest); + } arcRepository.deleteById(id); } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignReadinessAssessment.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReadinessAssessment.java new file mode 100644 index 0000000..465cad5 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReadinessAssessment.java @@ -0,0 +1,23 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.ReadinessStatus; + +import java.util.List; +import java.util.Map; + +/** + * Bilan de préparation d'une campagne (read-model, Pilier B « guidage »). + * + * @param campaignId campagne évaluée + * @param overallStatus statut agrégé (DRAFT si ≥1 gap bloquant, PLAYABLE si ≥1 + * recommandé sans bloquant, POLISHED sinon) + * @param counts nombre de gaps par sévérité ({@code "BLOCKING"|"RECOMMENDED"|"OPTIONAL"}) + * @param gaps liste des manques, triés par gravité décroissante + */ +public record CampaignReadinessAssessment( + String campaignId, + ReadinessStatus overallStatus, + Map counts, + List gaps +) { +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignReadinessService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReadinessService.java new file mode 100644 index 0000000..583bd8b --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReadinessService.java @@ -0,0 +1,320 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Enemy; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.ReadinessEntityType; +import com.loremind.domain.campaigncontext.ReadinessSeverity; +import com.loremind.domain.campaigncontext.ReadinessStatus; +import com.loremind.domain.campaigncontext.Room; +import com.loremind.domain.campaigncontext.RoomBranch; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.EnemyRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Service applicatif du Pilier B (« guidage / readiness ») : calcule à la volée + * l'état de préparation d'une campagne et la liste des manques (« gaps ») à combler, + * chacun cliquable vers l'éditeur concerné côté front. + * + *

Déterministe, sans IA, sans persistance (aucune colonne readiness — recalcul + * à chaque appel, comme le statut de quête). Orthogonalité stricte : n'injecte + * AUCUN repository du Play Context ; le readiness ne dépend jamais d'un Playthrough, + * d'un flag ou d'une progression. Purement indicatif : aucune sévérité ne bloque une action.

+ * + *

Chargement en une passe façon {@code CampaignStructuralContextBuilder} : arcs → + * chapitres (par arc) → scènes (par chapitre), + quêtes et bestiaire de la campagne + * chargés une seule fois, indexés par id pour résoudre les références faibles sans N+1.

+ * + *

Périmètre MVP : le noyau BLOQUANT (vides, branches/portes cassées, quête sans nœud + * ou à nœud mort, prérequis cassé) + le trio « combat » RECOMMANDÉ (combat annoncé sans + * ennemi, réf d'ennemi cassée en scène et en pièce). Les règles narratives/ambiance et + * les orphelins (état que l'UI empêche) sont hors MVP.

+ */ +@Service +public class CampaignReadinessService { + + private final CampaignRepository campaignRepository; + private final ArcRepository arcRepository; + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + private final QuestRepository questRepository; + private final EnemyRepository enemyRepository; + + public CampaignReadinessService(CampaignRepository campaignRepository, + ArcRepository arcRepository, + ChapterRepository chapterRepository, + SceneRepository sceneRepository, + QuestRepository questRepository, + EnemyRepository enemyRepository) { + this.campaignRepository = campaignRepository; + this.arcRepository = arcRepository; + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + this.questRepository = questRepository; + this.enemyRepository = enemyRepository; + } + + /** Évalue la préparation d'une campagne (arbre + quêtes) et agrège son statut. */ + public CampaignReadinessAssessment assess(String campaignId) { + List gaps = new ArrayList<>(); + + String campaignName = campaignRepository.findById(campaignId) + .map(Campaign::getName).orElse(null); + + // Bestiaire de la campagne, indexé pour résoudre les weak refs sans N+1. + // On écarte les ids vides/blancs : ils ne doivent jamais « résoudre » une référence. + Set enemyIds = enemyRepository.findByCampaignId(campaignId).stream() + .map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet()); + + // Index global chapitres / scènes (cibles possibles des nœuds de quête). + Set allChapterIds = new HashSet<>(); + Set allSceneIds = new HashSet<>(); + int totalScenes = 0; + + List quests = questRepository.findByCampaignId(campaignId); + // Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides + // (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres). + Set arcsWithQuests = quests.stream() + .map(Quest::getArcId).filter(id -> id != null && !id.isBlank()) + .collect(Collectors.toSet()); + + List arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId)); + arcs.sort(Comparator.comparingInt(Arc::getOrder)); + for (Arc arc : arcs) { + List chapters = chapterRepository.findByArcId(arc.getId()); + // Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la + // plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs). + boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId()); + if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) { + String msg = arc.getType() == ArcType.HUB + ? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le." + : "Arc vide : ajoutez un chapitre, ou supprimez-le."; + gaps.add(new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"), + "ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null)); + } + for (Chapter chapter : chapters) { + allChapterIds.add(chapter.getId()); + List scenes = sceneRepository.findByChapterId(chapter.getId()); + if (scenes.isEmpty()) { + gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(), + labelOr(chapter.getName(), "Chapitre"), "CHAP-001-NO-SCENE", + "Chapitre vide : ajoutez au moins une scène pour pouvoir le jouer.", + ReadinessSeverity.BLOCKING, arc.getId(), chapter.getId())); + } + Set chapterSceneIds = scenes.stream() + .map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + totalScenes += scenes.size(); + for (Scene scene : scenes) { + allSceneIds.add(scene.getId()); + checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps); + } + } + } + + Set questIds = quests.stream() + .map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + + // Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat). + boolean anyQuestWithNodes = quests.stream() + .anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty()); + if (totalScenes == 0 && !anyQuestWithNodes) { + gaps.add(new ReadinessGap(ReadinessEntityType.CAMPAIGN, campaignId, campaignName, + "CAMP-001-NO-CONTENT", + "Campagne vide : ajoutez un arc avec une scène, ou créez une quête, pour commencer à jouer.", + ReadinessSeverity.BLOCKING, null, null)); + } + + for (Quest quest : quests) { + checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps); + } + + return aggregate(campaignId, gaps); + } + + private void checkScene(Scene scene, String arcId, String chapterId, + Set chapterSceneIds, Set enemyIds, List gaps) { + String name = labelOr(scene.getName(), "Scène"); + + // SCENE-001 — scène sans titre. + if (isBlank(scene.getName())) { + gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME", + "Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.", + ReadinessSeverity.BLOCKING)); + } + + // SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence). + List branches = scene.getBranches(); + if (branches != null && branches.stream().anyMatch(b -> + isBlank(b.targetSceneId()) + || b.targetSceneId().equals(scene.getId()) + || !chapterSceneIds.contains(b.targetSceneId()))) { + gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID", + "Branche cassée : une sortie de « " + name + + " » pointe dans le vide, hors du chapitre, ou sur elle-même.", + ReadinessSeverity.BLOCKING)); + } + + // SCENE-011 — combat annoncé sans adversaire (règle produit clé). + if (!isBlank(scene.getCombatDifficulty())) { + boolean hasEnemyText = !isBlank(scene.getEnemies()); + boolean hasResolvedEnemy = scene.getEnemyIds() != null + && scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id)); + if (!hasEnemyText && !hasResolvedEnemy) { + gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-011-COMBAT-NO-ENEMY", + "Combat annoncé sans adversaire : ajoutez une fiche du bestiaire ou décrivez les ennemis.", + ReadinessSeverity.RECOMMENDED)); + } + } + + // SCENE-012 — référence d'ennemi cassée (fiche supprimée). + if (scene.getEnemyIds() != null + && scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id))) { + gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN", + "Ennemi introuvable : « " + name + + " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.", + ReadinessSeverity.RECOMMENDED)); + } + + // SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes. + List rooms = scene.getRooms(); + if (rooms != null && !rooms.isEmpty()) { + Set roomIds = rooms.stream() + .map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + boolean roomBranchInvalid = false; + boolean roomEnemyBroken = false; + for (Room room : rooms) { + if (room.getBranches() != null) { + for (RoomBranch rb : room.getBranches()) { + if (isBlank(rb.targetRoomId()) + || rb.targetRoomId().equals(room.getId()) + || !roomIds.contains(rb.targetRoomId())) { + roomBranchInvalid = true; + } + } + } + if (room.getEnemyIds() != null) { + for (String id : room.getEnemyIds()) { + if (!isBlank(id) && !enemyIds.contains(id)) { + roomEnemyBroken = true; + } + } + } + } + if (roomBranchInvalid) { + gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID", + "Porte cassée : dans « " + name + + " », une sortie de pièce pointe hors de la scène ou dans le vide.", + ReadinessSeverity.BLOCKING)); + } + if (roomEnemyBroken) { + gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN", + "Ennemi introuvable dans une pièce de « " + name + + " » : la référence pointe vers une fiche supprimée.", + ReadinessSeverity.RECOMMENDED)); + } + } + } + + private void checkQuest(Quest quest, Set allChapterIds, Set allSceneIds, + Set questIds, List gaps) { + String name = labelOr(quest.getName(), "Quête"); + + // QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide. + if (quest.getNodes() == null || quest.getNodes().isEmpty()) { + gaps.add(questGap(quest, "QUEST-001-NO-NODES", + "Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " ».", + ReadinessSeverity.BLOCKING)); + } else if (quest.getNodes().stream().anyMatch(n -> + n.nodeType() == null + || isBlank(n.nodeId()) + || (n.nodeType() == NodeType.CHAPTER && !allChapterIds.contains(n.nodeId())) + || (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) { + gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN", + "Nœud de quête cassé : dans « " + name + + " », un chapitre ou une scène référencé n'existe plus.", + ReadinessSeverity.BLOCKING)); + } + + // CAMP-010 — prérequis QuestCompleted pointant une quête disparue. + if (quest.getPrerequisites() != null && quest.getPrerequisites().stream() + .filter(p -> p instanceof Prerequisite.QuestCompleted) + .map(p -> ((Prerequisite.QuestCompleted) p).questId()) + .anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) { + gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ", + "Prérequis cassé : « " + name + + " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage.", + ReadinessSeverity.BLOCKING)); + } + } + + private CampaignReadinessAssessment aggregate(String campaignId, List gaps) { + Map counts = new LinkedHashMap<>(); + counts.put(ReadinessSeverity.BLOCKING.name(), 0); + counts.put(ReadinessSeverity.RECOMMENDED.name(), 0); + counts.put(ReadinessSeverity.OPTIONAL.name(), 0); + for (ReadinessGap g : gaps) { + counts.merge(g.severity().name(), 1, Integer::sum); + } + + ReadinessStatus status; + if (counts.get(ReadinessSeverity.BLOCKING.name()) > 0) { + status = ReadinessStatus.DRAFT; + } else if (counts.get(ReadinessSeverity.RECOMMENDED.name()) > 0) { + status = ReadinessStatus.PLAYABLE; + } else { + status = ReadinessStatus.POLISHED; + } + + gaps.sort(Comparator.comparingInt(g -> severityRank(g.severity()))); + return new CampaignReadinessAssessment(campaignId, status, counts, gaps); + } + + private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId, + String ruleId, String message, ReadinessSeverity severity) { + return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(), + labelOr(scene.getName(), "Scène"), ruleId, message, severity, arcId, chapterId); + } + + private ReadinessGap questGap(Quest quest, String ruleId, String message, ReadinessSeverity severity) { + return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(), + labelOr(quest.getName(), "Quête"), ruleId, message, severity, null, null); + } + + private static int severityRank(ReadinessSeverity severity) { + return switch (severity) { + case BLOCKING -> 0; + case RECOMMENDED -> 1; + case OPTIONAL -> 2; + }; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + private static String labelOr(String value, String fallback) { + return isBlank(value) ? fallback : value; + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java index e9175db..aeec48c 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java @@ -1,10 +1,8 @@ package com.loremind.application.campaigncontext; -import com.loremind.domain.campaigncontext.Arc; -import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Prerequisite; -import com.loremind.domain.campaigncontext.ports.ArcRepository; -import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.ports.QuestRepository; import org.springframework.stereotype.Service; import java.util.List; @@ -12,35 +10,33 @@ import java.util.TreeSet; /** * Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet}) - * référencés par les chapitres d'une Campagne. + * référencés par les quêtes d'une Campagne. * *

Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés * globalement. Un fait existe dès qu'au moins une quête le référence dans ses * prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans - * la Partie, autocomplete dans l'éditeur de prérequis).

+ * la Partie, autocomplete dans l'éditeur de prérequis de quête).

+ * + *

Niveau 1 : lit désormais les quêtes (entité de première classe), plus les + * chapitres HUB.

*/ @Service public class CampaignReferencedFlagsService { - private final ArcRepository arcRepository; - private final ChapterRepository chapterRepository; + private final QuestRepository questRepository; - public CampaignReferencedFlagsService(ArcRepository arcRepository, - ChapterRepository chapterRepository) { - this.arcRepository = arcRepository; - this.chapterRepository = chapterRepository; + public CampaignReferencedFlagsService(QuestRepository questRepository) { + this.questRepository = questRepository; } /** Retourne la liste triée alphabétiquement des noms de faits référencés. */ public List listForCampaign(String campaignId) { TreeSet unique = new TreeSet<>(); - for (Arc arc : arcRepository.findByCampaignId(campaignId)) { - for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) { - if (chapter.getPrerequisites() == null) continue; - for (Prerequisite p : chapter.getPrerequisites()) { - if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) { - unique.add(f.flagName()); - } + for (Quest quest : questRepository.findByCampaignId(campaignId)) { + if (quest.getPrerequisites() == null) continue; + for (Prerequisite p : quest.getPrerequisites()) { + if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) { + unique.add(f.flagName()); } } } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java index 354bece..e1c3d37 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java @@ -1,6 +1,7 @@ package com.loremind.application.campaigncontext; import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; @@ -81,6 +82,35 @@ public class ChapterService { return chapterRepository.save(chapter); } + /** + * Patch CIBLÉ champ-par-champ d'un chapitre (Pilier A — co-création). Applique + * UNIQUEMENT les {@link FieldProposal} reçus ; les autres champs restent INTACTS + * (contraste voulu avec {@link #updateChapter} qui écrase tout via BeanUtils). + */ + @Transactional + public Chapter patchChapter(String id, List fields) { + Chapter chapter = chapterRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Chapter non trouvé avec l'ID: " + id)); + if (fields != null) { + for (FieldProposal f : fields) { + if (f == null || f.key() == null) continue; + applyField(chapter, f.key(), f.proposedValue()); + } + } + return chapterRepository.save(chapter); + } + + /** Whitelist STRICTE des champs étoffables d'un chapitre ; clé inconnue ignorée. */ + private void applyField(Chapter chapter, String key, String value) { + switch (key) { + case "description" -> chapter.setDescription(value); + case "gmNotes" -> chapter.setGmNotes(value); + case "playerObjectives" -> chapter.setPlayerObjectives(value); + case "narrativeStakes" -> chapter.setNarrativeStakes(value); + default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ } + } + } + /** Compte des scènes qui tomberont avec le chapitre. */ public DeletionImpact getDeletionImpact(String id) { return new DeletionImpact(sceneRepository.findByChapterId(id).size()); diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java deleted file mode 100644 index eaf6bfe..0000000 --- a/core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.loremind.application.campaigncontext; - -import com.loremind.domain.campaigncontext.Chapter; -import com.loremind.domain.campaigncontext.PrerequisiteEvaluator; -import com.loremind.domain.campaigncontext.ProgressionStatus; -import com.loremind.domain.campaigncontext.QuestStatus; -import com.loremind.domain.playcontext.QuestProgression; -import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; -import com.loremind.domain.playcontext.ports.PlaythroughRepository; -import com.loremind.domain.playcontext.ports.QuestProgressionRepository; -import com.loremind.domain.playcontext.ports.SessionRepository; -import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO; -import org.springframework.stereotype.Service; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Service applicatif : enrichit des ChapterDTO avec leur {@link QuestStatus} effectif, - * relatif à un Playthrough donné. - * - *

Depuis l'introduction de Playthrough : la progression et les flags vivent au niveau - * de la Partie, plus de la Campagne. L'enrichissement nécessite donc un playthroughId.

- */ -@Service -public class ChapterStatusEnricher { - - private final PlaythroughRepository playthroughRepository; - private final QuestProgressionRepository progressionRepository; - private final PlaythroughFlagRepository flagRepository; - private final SessionRepository sessionRepository; - private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator(); - - public ChapterStatusEnricher(PlaythroughRepository playthroughRepository, - QuestProgressionRepository progressionRepository, - PlaythroughFlagRepository flagRepository, - SessionRepository sessionRepository) { - this.playthroughRepository = playthroughRepository; - this.progressionRepository = progressionRepository; - this.flagRepository = flagRepository; - this.sessionRepository = sessionRepository; - } - - /** Contexte d'évaluation + map chapterId -> ProgressionStatus pour ce Playthrough. */ - public record PlaythroughEvalSnapshot( - PrerequisiteEvaluator.EvaluationContext ctx, - Map progressionByChapterId - ) {} - - /** Construit le snapshot d'évaluation pour un Playthrough. */ - public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) { - if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) { - return new PlaythroughEvalSnapshot( - new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()), - Collections.emptyMap() - ); - } - Map flags = flagRepository.findByPlaythroughId(playthroughId); - Set completedQuestIds = progressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId); - int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size(); - - Map progressionMap = new HashMap<>(); - for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) { - progressionMap.put(qp.getChapterId(), qp.getStatus()); - } - - return new PlaythroughEvalSnapshot( - new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags), - progressionMap - ); - } - - /** Calcule le statut effectif d'un seul chapitre relatif à un Playthrough. */ - public QuestStatus computeFor(Chapter chapter, String playthroughId) { - PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId); - ProgressionStatus progression = snap.progressionByChapterId() - .getOrDefault(chapter.getId(), ProgressionStatus.NOT_STARTED); - return evaluator.computeStatus(progression, chapter.getPrerequisites(), snap.ctx()); - } - - /** - * Injecte le {@code effectiveStatus} et le {@code progressionStatus} dans une liste de DTOs. - * Un seul build du snapshot pour toute la liste (optimal pour les vues qui listent un arc). - */ - public void enrich(List dtos, List domain, String playthroughId) { - if (dtos == null || dtos.isEmpty()) return; - PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId); - Map byId = domain.stream() - .collect(Collectors.toMap(Chapter::getId, c -> c)); - for (ChapterDTO dto : dtos) { - Chapter c = byId.get(dto.getId()); - if (c == null) continue; - ProgressionStatus progression = snap.progressionByChapterId() - .getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED); - QuestStatus status = evaluator.computeStatus(progression, c.getPrerequisites(), snap.ctx()); - dto.setProgressionStatus(progression.name()); - dto.setEffectiveStatus(status.name()); - } - } -} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java b/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java new file mode 100644 index 0000000..75d9af2 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java @@ -0,0 +1,219 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import com.loremind.domain.playcontext.ports.QuestProgressionRepository; +import com.loremind.domain.shared.ReorderSupport; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Service d'application pour le contexte Quest (Niveau 1). + * Orchestre la logique métier via le Port {@code QuestRepository}. + */ +@Service +public class QuestService { + + /** Nom de l'arc technique hébergeant les conteneurs des quêtes libres (invisible). */ + static final String SYSTEM_ARC_NAME = "Quêtes libres"; + + private final QuestRepository questRepository; + private final QuestProgressionRepository progressionRepository; + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + private final ArcRepository arcRepository; + + public QuestService(QuestRepository questRepository, + QuestProgressionRepository progressionRepository, + ChapterRepository chapterRepository, + SceneRepository sceneRepository, + ArcRepository arcRepository) { + this.questRepository = questRepository; + this.progressionRepository = progressionRepository; + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + this.arcRepository = arcRepository; + } + + /** + * Création à partir d'une Quest complète. L'id est forcé à null (généré par la DB). + * + *

TOUTE quête créée sans nœud reçoit son CONTENEUR de scènes (chapitre jumeau, + * même nom, masqué dans l'arbre par la fusion quête/jumeau) : une quête est un espace + * jouable où le MJ crée ses scènes à la volée — qu'elle vive dans un arc HUB (le + * conteneur y est rangé) ou LIBRE (le conteneur va dans l'arc technique {@code SYSTEM} + * de la campagne, invisible et non exporté). Lier des nœuds existants à la création + * (quête « transversale ») court-circuite le provisioning.

+ */ + @Transactional + public Quest createQuest(Quest input) { + input.setId(null); + if (nullSafeNodes(input.getNodes()).isEmpty()) { + provisionContainer(input); + } + return questRepository.save(input); + } + + /** + * Provisionne le conteneur de scènes d'une quête sans nœud et le référence + * (mutation de {@code quest.nodes} — la sauvegarde reste à la charge de l'appelant). + */ + private void provisionContainer(Quest quest) { + String containerArcId = quest.getArcId() != null && !quest.getArcId().isBlank() + ? quest.getArcId() + : systemArcIdFor(quest.getCampaignId()); + int order = chapterRepository.findByArcId(containerArcId).stream() + .mapToInt(Chapter::getOrder).max().orElse(-1) + 1; + Chapter container = chapterRepository.save(Chapter.builder() + .name(quest.getName()) + .description("") // le narratif vit sur la quête, pas sur le conteneur + .arcId(containerArcId) + .order(order) + .build()); + quest.setNodes(new ArrayList<>(List.of( + new QuestNodeRef(NodeType.CHAPTER, container.getId(), 0)))); + } + + /** Arc technique (SYSTEM) de la campagne — créé au premier besoin. */ + private String systemArcIdFor(String campaignId) { + return arcRepository.findByCampaignId(campaignId).stream() + .filter(a -> a.getType() == ArcType.SYSTEM) + .map(Arc::getId) + .findFirst() + .orElseGet(() -> arcRepository.save(Arc.builder() + .name(SYSTEM_ARC_NAME) + .description("") + .campaignId(campaignId) + .type(ArcType.SYSTEM) + .order(9999) + .build()).getId()); + } + + /** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */ + private boolean isContainerOf(Quest quest, Chapter chapter) { + if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true; + return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId()) + .map(a -> a.getType() == ArcType.SYSTEM) + .orElse(false); + } + + public Optional getQuestById(String id) { + return questRepository.findById(id); + } + + public List getQuestsByCampaignId(String campaignId) { + return questRepository.findByCampaignId(campaignId); + } + + /** Quêtes rattachées à un arc HUB. */ + public List getQuestsByArcId(String arcId) { + return questRepository.findByArcId(arcId); + } + + public List getAllQuests() { + return questRepository.findAll(); + } + + /** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */ + @Transactional + public Quest updateQuest(String id, Quest updated) { + Optional existing = questRepository.findById(id); + if (existing.isEmpty()) { + throw new IllegalArgumentException("Quest non trouvée avec l'ID: " + id); + } + Quest quest = existing.get(); + String oldName = quest.getName(); + BeanUtils.copyProperties(updated, quest, "id"); + Quest saved = questRepository.save(quest); + // Le conteneur jumeau porte le même nom que la quête (fusion dans l'arbre) : + // il suit le renommage, sinon le guidage citerait encore l'ancien nom. + // Vaut pour les quêtes de hub COMME pour les quêtes libres (conteneur en arc SYSTEM). + if (oldName != null && !oldName.equals(saved.getName())) { + for (QuestNodeRef node : nullSafeNodes(saved.getNodes())) { + if (node.nodeType() != NodeType.CHAPTER) continue; + chapterRepository.findById(node.nodeId()).ifPresent(ch -> { + if (oldName.equals(ch.getName()) && isContainerOf(saved, ch)) { + ch.setName(saved.getName()); + chapterRepository.save(ch); + } + }); + } + } + // Auto-réparation : une quête historique restée sans nœud (créée avant le + // provisioning systématique) reçoit son espace de scènes à la première sauvegarde. + if (nullSafeNodes(saved.getNodes()).isEmpty()) { + provisionContainer(saved); + return questRepository.save(saved); + } + return saved; + } + + /** + * Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties. + * + *

TODO (Phase 5) : signaler/nettoyer les {@code Prerequisite.QuestCompleted} pendants + * d'autres quêtes qui pointaient celle-ci. Échec sûr aujourd'hui : un prérequis vers une + * quête supprimée n'est jamais satisfait → la quête dépendante reste LOCKED (pas de corruption).

+ */ + @Transactional + public void deleteQuest(String id) { + Quest quest = questRepository.findById(id).orElse(null); + progressionRepository.deleteByQuestId(id); + questRepository.deleteById(id); + if (quest == null) return; + // Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE + // (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître + // comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune + // perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du + // contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut. + List remaining = questRepository.findByCampaignId(quest.getCampaignId()); + for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) { + if (node.nodeType() != NodeType.CHAPTER) continue; + chapterRepository.findById(node.nodeId()).ifPresent(ch -> { + boolean container = isContainerOf(quest, ch); + boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty(); + boolean referencedElsewhere = remaining.stream() + .anyMatch(q -> nullSafeNodes(q.getNodes()).stream() + .anyMatch(n -> n.nodeType() == NodeType.CHAPTER + && ch.getId().equals(n.nodeId()))); + if (container && empty && !referencedElsewhere) { + chapterRepository.deleteById(ch.getId()); + } + }); + } + } + + private static List nullSafeNodes(List nodes) { + return nodes != null ? nodes : List.of(); + } + + public boolean questExists(String id) { + return questRepository.existsById(id); + } + + /** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */ + @Transactional + public void reorderQuests(String campaignId, List orderedIds) { + ReorderSupport.reorder(orderedIds, + id -> questRepository.findById(id).orElse(null), + (quest, i) -> { + if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId); + quest.setOrder(i); + }, + questRepository::save); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/QuestStatusEnricher.java b/core/src/main/java/com/loremind/application/campaigncontext/QuestStatusEnricher.java new file mode 100644 index 0000000..4c1a70d --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/QuestStatusEnricher.java @@ -0,0 +1,122 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.PrerequisiteEvaluator; +import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.QuestStatus; +import com.loremind.domain.playcontext.QuestProgression; +import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.domain.playcontext.ports.QuestProgressionRepository; +import com.loremind.domain.playcontext.ports.SessionRepository; +import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Service applicatif : enrichit des {@link QuestDTO} avec leur {@link QuestStatus} + * effectif, relatif à un Playthrough donné. + * + *

Depuis l'introduction de Playthrough, la progression et les flags vivent au niveau + * de la Partie. L'enrichissement nécessite donc un playthroughId ; sans lui (ou s'il est + * inconnu), le snapshot est vide et tout est NOT_STARTED / AVAILABLE.

+ */ +@Service +public class QuestStatusEnricher { + + private final PlaythroughRepository playthroughRepository; + private final QuestProgressionRepository progressionRepository; + private final PlaythroughFlagRepository flagRepository; + private final SessionRepository sessionRepository; + private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator(); + + public QuestStatusEnricher(PlaythroughRepository playthroughRepository, + QuestProgressionRepository progressionRepository, + PlaythroughFlagRepository flagRepository, + SessionRepository sessionRepository) { + this.playthroughRepository = playthroughRepository; + this.progressionRepository = progressionRepository; + this.flagRepository = flagRepository; + this.sessionRepository = sessionRepository; + } + + /** Contexte d'évaluation + map questId -> ProgressionStatus pour ce Playthrough. */ + public record PlaythroughEvalSnapshot( + PrerequisiteEvaluator.EvaluationContext ctx, + Map progressionByQuestId + ) {} + + /** Construit le snapshot d'évaluation pour un Playthrough (court-circuit si null / inconnu). */ + public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) { + if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) { + return new PlaythroughEvalSnapshot( + new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()), + Collections.emptyMap() + ); + } + Map flags = flagRepository.findByPlaythroughId(playthroughId); + Set completedQuestIds = progressionRepository.findCompletedQuestIdsByPlaythroughId(playthroughId); + int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size(); + + Map progressionMap = new HashMap<>(); + for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) { + progressionMap.put(qp.getQuestId(), qp.getStatus()); + } + + return new PlaythroughEvalSnapshot( + new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags), + progressionMap + ); + } + + /** Calcule le statut effectif d'une seule quête relatif à un Playthrough. */ + public QuestStatus computeFor(Quest quest, String playthroughId) { + PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId); + ProgressionStatus progression = snap.progressionByQuestId() + .getOrDefault(quest.getId(), ProgressionStatus.NOT_STARTED); + return evaluator.computeStatus(progression, quest.getPrerequisites(), snap.ctx()); + } + + /** + * Statut effectif de PLUSIEURS quêtes avec un seul build du snapshot (contrairement + * à {@link #computeFor} qui reconstruit le snapshot à chaque appel). Utilisé par les + * read-models qui balaient toutes les quêtes d'une campagne (préparation de séance). + */ + public Map computeAll(List quests, String playthroughId) { + PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId); + Map out = new HashMap<>(); + for (Quest q : quests) { + if (q == null || q.getId() == null) continue; + ProgressionStatus progression = snap.progressionByQuestId() + .getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED); + out.put(q.getId(), evaluator.computeStatus(progression, q.getPrerequisites(), snap.ctx())); + } + return out; + } + + /** + * Injecte {@code progressionStatus} + {@code effectiveStatus} dans une liste de DTOs. + * Un seul build du snapshot pour toute la liste. + */ + public void enrich(List dtos, List domain, String playthroughId) { + if (dtos == null || dtos.isEmpty()) return; + PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId); + Map byId = domain.stream() + .collect(Collectors.toMap(Quest::getId, q -> q)); + for (QuestDTO dto : dtos) { + Quest q = byId.get(dto.getId()); + if (q == null) continue; + ProgressionStatus progression = snap.progressionByQuestId() + .getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED); + QuestStatus status = evaluator.computeStatus(progression, q.getPrerequisites(), snap.ctx()); + dto.setProgressionStatus(progression.name()); + dto.setEffectiveStatus(status.name()); + } + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ReadinessGap.java b/core/src/main/java/com/loremind/application/campaigncontext/ReadinessGap.java new file mode 100644 index 0000000..1e72c8a --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/ReadinessGap.java @@ -0,0 +1,34 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.ReadinessEntityType; +import com.loremind.domain.campaigncontext.ReadinessSeverity; + +/** + * Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B). + * + *

Le message est déjà rédigé (orienté action, bienveillant) côté back ; le front + * l'affiche tel quel. {@code arcId}/{@code chapterId} sont le CONTEXTE de navigation + * (nullable selon l'entité) : le front construit le lien profond vers l'éditeur à + * partir de {@code entityType}, {@code entityId} et de ces ancêtres — aucune route + * Angular n'est codée côté back.

+ * + * @param entityType type de l'entité concernée + * @param entityId id de l'entité concernée + * @param entityName libellé lisible de l'entité (peut être {@code null} si sans nom) + * @param ruleId identifiant stable de la règle (ex. {@code SCENE-011-COMBAT-NO-ENEMY}) + * @param message message utilisateur prêt à afficher + * @param severity gravité du manque + * @param arcId arc parent (navigation), ou {@code null} + * @param chapterId chapitre parent (navigation), ou {@code null} + */ +public record ReadinessGap( + ReadinessEntityType entityType, + String entityId, + String entityName, + String ruleId, + String message, + ReadinessSeverity severity, + String arcId, + String chapterId +) { +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java index 39f4968..aac895b 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java @@ -1,6 +1,8 @@ package com.loremind.application.campaigncontext; +import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneDraft; import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.ports.SceneRepository; @@ -86,7 +88,96 @@ public class SceneService { return sceneRepository.save(scene); } + /** + * Patch CIBLÉ champ-par-champ d'une scène (Pilier A — co-création). Applique + * UNIQUEMENT les {@link FieldProposal} reçus (valeurs acceptées par l'utilisateur) sur + * les champs correspondants ; tous les autres champs restent INTACTS. + * + *

Contraste volontaire avec {@link #updateScene} : ce dernier fait un + * {@code BeanUtils.copyProperties} qui écrase MÊME avec des null — inadapté ici où l'on + * ne veut toucher que les champs proposés. Les branches ne sont pas modifiées (pas de + * revalidation du graphe nécessaire).

+ */ + @Transactional + public Scene patchScene(String id, List fields) { + Scene scene = sceneRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Scene non trouvée avec l'ID: " + id)); + if (fields != null) { + for (FieldProposal f : fields) { + if (f == null || f.key() == null) continue; + applyField(scene, f.key(), f.proposedValue()); + } + } + return sceneRepository.save(scene); + } + + /** + * Applique une valeur sur le champ nommé. Whitelist STRICTE alignée sur + * {@code NarrativeEntityContextBuilder.fromScene()} : toute clé inconnue est ignorée + * (jamais d'écrasement hors de la liste connue). + */ + private void applyField(Scene scene, String key, String value) { + switch (key) { + case "description" -> scene.setDescription(value); + case "location" -> scene.setLocation(value); + case "timing" -> scene.setTiming(value); + case "atmosphere" -> scene.setAtmosphere(value); + case "playerNarration" -> scene.setPlayerNarration(value); + case "choicesConsequences" -> scene.setChoicesConsequences(value); + case "combatDifficulty" -> scene.setCombatDifficulty(value); + case "enemies" -> scene.setEnemies(value); + case "gmSecretNotes" -> scene.setGmSecretNotes(value); + default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ } + } + } + + /** + * Crée en bloc des scènes à partir d'ébauches IA acceptées (Pilier A — capacité + * « create »). Les scènes sont AJOUTÉES à la fin du chapitre (ordre = suite des scènes + * existantes). Les ébauches sans titre sont ignorées. Transactionnel. + */ + @Transactional + public List createDraftScenes(String chapterId, List drafts) { + if (drafts == null || drafts.isEmpty()) return List.of(); + int order = sceneRepository.findByChapterId(chapterId).stream() + .mapToInt(Scene::getOrder).max().orElse(-1) + 1; + List created = new ArrayList<>(); + for (SceneDraft d : drafts) { + if (d == null || d.name() == null || d.name().isBlank()) continue; + Scene scene = Scene.builder() + .name(d.name().trim()) + .description(d.description()) + .playerNarration(d.playerNarration()) + .chapterId(chapterId) + .order(order++) + .build(); + created.add(createScene(scene)); // createScene(Scene) force id=null + rooms + } + return created; + } + + /** + * Supprime la scène ET nettoie les branches des scènes sœurs qui pointaient vers elle + * (sinon elles deviennent des références mortes : invisibles dans le graphe — qui filtre + * les cibles inexistantes — mais signalées « branche cassée » par le guidage, ce qui est + * incompréhensible pour l'utilisateur). Les branches étant intra-chapitre, le nettoyage + * se limite aux sœurs du même chapitre. Transactionnel : atomique. + */ + @Transactional public void deleteScene(String id) { + sceneRepository.findById(id).ifPresent(scene -> { + for (Scene sibling : sceneRepository.findByChapterId(scene.getChapterId())) { + List branches = sibling.getBranches(); + if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue; + List kept = branches.stream() + .filter(b -> !id.equals(b.targetSceneId())) + .collect(Collectors.toList()); + if (kept.size() != branches.size()) { + sibling.setBranches(kept); + sceneRepository.save(sibling); + } + } + }); sceneRepository.deleteById(id); } diff --git a/core/src/main/java/com/loremind/application/generationcontext/DraftScenesUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/DraftScenesUseCase.java new file mode 100644 index 0000000..efda8cb --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/DraftScenesUseCase.java @@ -0,0 +1,105 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneDraft; +import com.loremind.domain.campaigncontext.SceneDraftProposal; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Use case (Pilier A — capacité « create ») : produit une PROPOSITION d'ébauches de scènes + * pour un chapitre (non persistée). Répond directement à la « page blanche » / au manque + * de guidage « Chapitre vide » (Pilier B). + * + *

Contexte compact : le chapitre (objectifs/enjeux) + les scènes DÉJÀ présentes (pour + * éviter les doublons) + méta campagne. Zéro écriture — la création est un second appel.

+ */ +@Service +public class DraftScenesUseCase { + + private static final int MIN_COUNT = 1; + private static final int MAX_COUNT = 8; + + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + private final CampaignRepository campaignRepository; + private final GameSystemRepository gameSystemRepository; + private final SceneDraftAssistant assistant; + + public DraftScenesUseCase(ChapterRepository chapterRepository, + SceneRepository sceneRepository, + CampaignRepository campaignRepository, + GameSystemRepository gameSystemRepository, + SceneDraftAssistant assistant) { + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + this.campaignRepository = campaignRepository; + this.gameSystemRepository = gameSystemRepository; + this.assistant = assistant; + } + + public SceneDraftProposal execute(String chapterId, String campaignId, String instruction, int count) { + Chapter chapter = chapterRepository.findById(chapterId) + .orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + chapterId)); + int n = Math.max(MIN_COUNT, Math.min(MAX_COUNT, count)); + + String context = buildContext(chapter, campaignId); + List drafts = assistant.draftScenes(context, instruction, n).stream() + .filter(d -> d != null && d.name() != null && !d.name().isBlank()) + .limit(n) + .collect(Collectors.toList()); + return new SceneDraftProposal(chapterId, drafts); + } + + private String buildContext(Chapter chapter, String campaignId) { + StringBuilder sb = new StringBuilder(); + sb.append("Chapitre : ").append(blankToLabel(chapter.getName(), "(sans titre)")).append("\n"); + appendIf(sb, "Synopsis", chapter.getDescription()); + appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives()); + appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes()); + + List existing = sceneRepository.findByChapterId(chapter.getId()); + if (!existing.isEmpty()) { + String names = existing.stream() + .map(Scene::getName) + .filter(nm -> nm != null && !nm.isBlank()) + .collect(Collectors.joining(" ; ")); + if (!names.isEmpty()) { + sb.append("Scènes DÉJÀ présentes (ne pas dupliquer) : ").append(names).append("\n"); + } + } + + if (campaignId != null && !campaignId.isBlank()) { + campaignRepository.findById(campaignId).ifPresent(c -> { + sb.append("Campagne : ").append(c.getName()); + if (c.getDescription() != null && !c.getDescription().isBlank()) { + sb.append(" — ").append(c.getDescription().trim()); + } + sb.append("\n"); + if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) { + gameSystemRepository.findById(c.getGameSystemId()) + .ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).append("\n")); + } + }); + } + return sb.toString(); + } + + private static void appendIf(StringBuilder sb, String label, String value) { + if (value != null && !value.isBlank()) { + sb.append(label).append(" : ").append(value.trim()).append("\n"); + } + } + + private static String blankToLabel(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/NarrativeAssistFieldsUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/NarrativeAssistFieldsUseCase.java new file mode 100644 index 0000000..1afacff --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/NarrativeAssistFieldsUseCase.java @@ -0,0 +1,112 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.campaigncontext.EntityFieldPatchProposal; +import com.loremind.domain.campaigncontext.FieldProposal; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Use case (Pilier A — co-création) : produit une PROPOSITION d'étoffage des champs d'une + * entité narrative (arc / chapitre / scène), non persistée. Générique par {@code entityType} + * grâce à {@link NarrativeFieldCatalog} (source de vérité des champs) : la même mécanique + * couvre les trois types, seul le catalogue de champs change. + * + *

Contexte volontairement COMPACT (entité + méta campagne). Zéro écriture — l'application + * est un second appel explicite (human-in-the-loop).

+ */ +@Service +public class NarrativeAssistFieldsUseCase { + + private final NarrativeFieldCatalog catalog; + private final CampaignRepository campaignRepository; + private final GameSystemRepository gameSystemRepository; + private final NarrativeFieldAssistant assistant; + + public NarrativeAssistFieldsUseCase( + NarrativeFieldCatalog catalog, + CampaignRepository campaignRepository, + GameSystemRepository gameSystemRepository, + NarrativeFieldAssistant assistant) { + this.catalog = catalog; + this.campaignRepository = campaignRepository; + this.gameSystemRepository = gameSystemRepository; + this.assistant = assistant; + } + + public EntityFieldPatchProposal execute(String entityType, String entityId, String campaignId, String instruction) { + NarrativeFieldCatalog.Snapshot snap = catalog.read(entityType, entityId); + + List specs = snap.defs().stream() + .map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label())) + .collect(Collectors.toList()); + Set allowed = snap.defs().stream() + .map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet()); + + String context = buildContext(campaignId, snap); + List proposed = + assistant.assist(snap.entityType(), context, instruction, specs); + + List fields = new ArrayList<>(); + for (NarrativeFieldAssistant.ProposedField pf : proposed) { + if (pf.key() == null || !allowed.contains(pf.key())) continue; + if (pf.value() == null || pf.value().isBlank()) continue; + String current = snap.current().getOrDefault(pf.key(), ""); + fields.add(new FieldProposal(pf.key(), current, pf.value())); + } + return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields); + } + + /** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */ + private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) { + StringBuilder sb = new StringBuilder(); + sb.append(entityLabel(snap.entityType())).append(" : ") + .append(blankToLabel(snap.title(), "(sans titre)")).append("\n"); + sb.append("État actuel des champs :\n"); + boolean any = false; + for (Map.Entry e : snap.current().entrySet()) { + String v = e.getValue(); + if (v != null && !v.isBlank()) { + sb.append("- ").append(e.getKey()).append(" : ").append(v.trim()).append("\n"); + any = true; + } + } + if (!any) { + sb.append("- (tous les champs sont vides — à créer de zéro)\n"); + } + if (campaignId != null && !campaignId.isBlank()) { + campaignRepository.findById(campaignId).ifPresent(c -> { + sb.append("Campagne : ").append(c.getName()); + if (c.getDescription() != null && !c.getDescription().isBlank()) { + sb.append(" — ").append(c.getDescription().trim()); + } + sb.append("\n"); + if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) { + gameSystemRepository.findById(c.getGameSystemId()) + .ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).append("\n")); + } + }); + } + return sb.toString(); + } + + private static String entityLabel(String entityType) { + return switch (entityType == null ? "" : entityType) { + case "arc" -> "Arc"; + case "chapter" -> "Chapitre"; + case "scene" -> "Scène"; + default -> "Entité"; + }; + } + + private static String blankToLabel(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/NarrativeFieldCatalog.java b/core/src/main/java/com/loremind/application/generationcontext/NarrativeFieldCatalog.java new file mode 100644 index 0000000..79af600 --- /dev/null +++ b/core/src/main/java/com/loremind/application/generationcontext/NarrativeFieldCatalog.java @@ -0,0 +1,126 @@ +package com.loremind.application.generationcontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; + +/** + * Catalogue des champs ÉTOFFABLES par l'IA (Pilier A), par type d'entité narrative. + * SOURCE DE VÉRITÉ unique côté génération : ordre + clés + libellés + valeurs actuelles. + * + *

Les clés correspondent aux setters des services (patch) et aux contrôles de + * formulaire côté front. Le champ {@code name}/{@code type} n'est jamais étoffable + * (identité / enum). La liaison clé → setter reste dans chaque service (persistance).

+ */ +@Component +public class NarrativeFieldCatalog { + + /** Définition d'un champ : clé technique + libellé (guide le prompt du Brain). */ + public record FieldDef(String key, String label) {} + + /** Instantané d'une entité pour l'étoffage : titre + valeurs actuelles + champs. */ + public record Snapshot(String entityType, String title, + LinkedHashMap current, List defs) {} + + private static final List ARC_DEFS = List.of( + new FieldDef("description", "description / synopsis de l'arc"), + new FieldDef("themes", "thèmes explorés"), + new FieldDef("stakes", "enjeux globaux pour les personnages"), + new FieldDef("rewards", "récompenses et progression"), + new FieldDef("resolution", "dénouement prévu"), + new FieldDef("gmNotes", "notes privées du MJ")); + + private static final List CHAPTER_DEFS = List.of( + new FieldDef("description", "synopsis du chapitre"), + new FieldDef("playerObjectives", "objectifs des joueurs"), + new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"), + new FieldDef("gmNotes", "notes privées du MJ")); + + private static final List SCENE_DEFS = List.of( + new FieldDef("description", "description courte de la scène"), + new FieldDef("location", "lieu où se déroule la scène"), + new FieldDef("timing", "moment / temporalité"), + new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"), + new FieldDef("playerNarration", "texte de mise en scène lu aux joueurs"), + new FieldDef("choicesConsequences", "choix offerts aux joueurs et leurs conséquences"), + new FieldDef("combatDifficulty", "difficulté de combat estimée"), + new FieldDef("enemies", "ennemis / créatures présentes (texte libre)"), + new FieldDef("gmSecretNotes", "notes secrètes du MJ (cachées des joueurs)")); + + private final ArcRepository arcRepository; + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + + public NarrativeFieldCatalog(ArcRepository arcRepository, + ChapterRepository chapterRepository, + SceneRepository sceneRepository) { + this.arcRepository = arcRepository; + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + } + + /** Charge l'entité et son instantané d'étoffage. @throws IllegalArgumentException si type/entité inconnu. */ + public Snapshot read(String entityType, String entityId) { + return switch (normalize(entityType)) { + case "arc" -> arcSnapshot(entityId); + case "chapter" -> chapterSnapshot(entityId); + case "scene" -> sceneSnapshot(entityId); + default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType); + }; + } + + private Snapshot arcSnapshot(String id) { + Arc a = arcRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id)); + LinkedHashMap cur = new LinkedHashMap<>(); + cur.put("description", nz(a.getDescription())); + cur.put("themes", nz(a.getThemes())); + cur.put("stakes", nz(a.getStakes())); + cur.put("rewards", nz(a.getRewards())); + cur.put("resolution", nz(a.getResolution())); + cur.put("gmNotes", nz(a.getGmNotes())); + return new Snapshot("arc", a.getName(), cur, ARC_DEFS); + } + + private Snapshot chapterSnapshot(String id) { + Chapter c = chapterRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id)); + LinkedHashMap cur = new LinkedHashMap<>(); + cur.put("description", nz(c.getDescription())); + cur.put("playerObjectives", nz(c.getPlayerObjectives())); + cur.put("narrativeStakes", nz(c.getNarrativeStakes())); + cur.put("gmNotes", nz(c.getGmNotes())); + return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS); + } + + private Snapshot sceneSnapshot(String id) { + Scene s = sceneRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id)); + LinkedHashMap cur = new LinkedHashMap<>(); + cur.put("description", nz(s.getDescription())); + cur.put("location", nz(s.getLocation())); + cur.put("timing", nz(s.getTiming())); + cur.put("atmosphere", nz(s.getAtmosphere())); + cur.put("playerNarration", nz(s.getPlayerNarration())); + cur.put("choicesConsequences", nz(s.getChoicesConsequences())); + cur.put("combatDifficulty", nz(s.getCombatDifficulty())); + cur.put("enemies", nz(s.getEnemies())); + cur.put("gmSecretNotes", nz(s.getGmSecretNotes())); + return new Snapshot("scene", s.getName(), cur, SCENE_DEFS); + } + + private static String normalize(String entityType) { + return entityType == null ? "" : entityType.trim().toLowerCase(); + } + + private static String nz(String v) { + return v == null ? "" : v; + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java index 2b97308..05b4953 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java @@ -1,13 +1,10 @@ package com.loremind.application.generationcontext; -import com.loremind.domain.campaigncontext.Arc; -import com.loremind.domain.campaigncontext.ArcType; -import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.PrerequisiteEvaluator; import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.QuestStatus; -import com.loremind.domain.campaigncontext.ports.ArcRepository; -import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.generationcontext.SessionContext; import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary; import com.loremind.domain.generationcontext.SessionContext.QuestSummary; @@ -46,8 +43,7 @@ public class SessionStructuralContextBuilder { private final SessionRepository sessionRepository; private final SessionEntryRepository entryRepository; private final PlaythroughRepository playthroughRepository; - private final ArcRepository arcRepository; - private final ChapterRepository chapterRepository; + private final QuestRepository questRepository; private final PlaythroughFlagRepository playthroughFlagRepository; private final QuestProgressionRepository questProgressionRepository; private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator(); @@ -55,15 +51,13 @@ public class SessionStructuralContextBuilder { public SessionStructuralContextBuilder(SessionRepository sessionRepository, SessionEntryRepository entryRepository, PlaythroughRepository playthroughRepository, - ArcRepository arcRepository, - ChapterRepository chapterRepository, + QuestRepository questRepository, PlaythroughFlagRepository playthroughFlagRepository, QuestProgressionRepository questProgressionRepository) { this.sessionRepository = sessionRepository; this.entryRepository = entryRepository; this.playthroughRepository = playthroughRepository; - this.arcRepository = arcRepository; - this.chapterRepository = chapterRepository; + this.questRepository = questRepository; this.playthroughFlagRepository = playthroughFlagRepository; this.questProgressionRepository = questProgressionRepository; } @@ -169,23 +163,14 @@ public class SessionStructuralContextBuilder { Map flags = playthroughFlagRepository.findByPlaythroughId(playthroughId); List activeFlags = buildActiveFlags(flags); - List arcs = arcRepository.findByCampaignId(campaignId); - Map arcsById = arcs.stream() - .filter(a -> a.getId() != null) - .collect(Collectors.toMap(Arc::getId, a -> a)); - - // On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET - // ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans - // condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes). - - // Map chapterId -> ProgressionStatus pour ce Playthrough - Map progressionByChapter = new HashMap<>(); + // Map questId -> ProgressionStatus pour ce Playthrough + Map progressionByQuest = new HashMap<>(); for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) { - progressionByChapter.put(qp.getChapterId(), qp.getStatus()); + progressionByQuest.put(qp.getQuestId(), qp.getStatus()); } - // IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted) - var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId); + // IDs des quêtes COMPLETED dans la campagne (pour les prérequis QuestCompleted) + var completedIds = questProgressionRepository.findCompletedQuestIdsByPlaythroughId(playthroughId); int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size(); PrerequisiteEvaluator.EvaluationContext ctx = @@ -195,29 +180,24 @@ public class SessionStructuralContextBuilder { List inProgress = new ArrayList<>(); List lockedTitles = new ArrayList<>(); - for (Arc arc : arcs) { - boolean isHub = arc.getType() == ArcType.HUB; - for (Chapter c : chapterRepository.findByArcId(arc.getId())) { - boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty(); - if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré - ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED); - QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx); - Arc parent = arcsById.get(c.getArcId()); - String arcName = parent != null ? parent.getName() : null; - switch (status) { - case AVAILABLE: - available.add(new QuestSummary(c.getName(), arcName, c.getDescription())); - break; - case IN_PROGRESS: - inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription())); - break; - case LOCKED: - lockedTitles.add(c.getName()); - break; - case COMPLETED: - // Omis (déjà dans le journal des EVENTs). - break; - } + // Niveau 1 : les quêtes sont des entités orthogonales rattachées à la campagne + // (plus des chapitres HUB). arcName n'a plus de sens => null. + for (Quest q : questRepository.findByCampaignId(campaignId)) { + ProgressionStatus prog = progressionByQuest.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED); + QuestStatus status = prerequisiteEvaluator.computeStatus(prog, q.getPrerequisites(), ctx); + switch (status) { + case AVAILABLE: + available.add(new QuestSummary(q.getName(), null, q.getDescription())); + break; + case IN_PROGRESS: + inProgress.add(new QuestSummary(q.getName(), null, q.getDescription())); + break; + case LOCKED: + lockedTitles.add(q.getName()); + break; + case COMPLETED: + // Omis (déjà dans le journal des EVENTs). + break; } } diff --git a/core/src/main/java/com/loremind/application/playcontext/ClockService.java b/core/src/main/java/com/loremind/application/playcontext/ClockService.java new file mode 100644 index 0000000..b7282dc --- /dev/null +++ b/core/src/main/java/com/loremind/application/playcontext/ClockService.java @@ -0,0 +1,141 @@ +package com.loremind.application.playcontext; + +import com.loremind.domain.playcontext.Clock; +import com.loremind.domain.playcontext.ClockTrigger; +import com.loremind.domain.playcontext.ports.ClockRepository; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Service applicatif des Horloges de progression (Clocks) d'une Partie. + * CRUD + avancer / reculer d'un segment, borné à [0, segments]. + */ +@Service +public class ClockService { + + /** Garde-fou : taille maximale d'une horloge. */ + private static final int MAX_SEGMENTS = 60; + + private final ClockRepository clockRepository; + private final PlaythroughRepository playthroughRepository; + + public ClockService(ClockRepository clockRepository, PlaythroughRepository playthroughRepository) { + this.clockRepository = clockRepository; + this.playthroughRepository = playthroughRepository; + } + + public List getByPlaythrough(String playthroughId) { + return clockRepository.findByPlaythroughId(playthroughId); + } + + public Clock create(String playthroughId, String name, String description, int segments, + ClockTrigger triggerType, String triggerRef, String frontId) { + if (playthroughId == null || !playthroughRepository.existsById(playthroughId)) { + throw new IllegalArgumentException("Partie introuvable : " + playthroughId); + } + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Le nom de l'horloge est requis."); + } + int order = clockRepository.findByPlaythroughId(playthroughId).size(); + ClockTrigger type = triggerType != null ? triggerType : ClockTrigger.NONE; + Clock clock = Clock.builder() + .playthroughId(playthroughId) + .name(name.trim()) + .description(description) + .segments(clampSegments(segments)) + .filled(0) + .order(order) + .triggerType(type) + .triggerRef(normalizeRef(type, triggerRef)) + .frontId(blankToNull(frontId)) + .build(); + return clockRepository.save(clock); + } + + public Clock update(String id, String name, String description, int segments, + ClockTrigger triggerType, String triggerRef, String frontId) { + Clock clock = require(id); + if (name != null && !name.isBlank()) clock.setName(name.trim()); + clock.setDescription(description); + int seg = clampSegments(segments); + clock.setSegments(seg); + if (clock.getFilled() > seg) clock.setFilled(seg); // ré-borne si on réduit la taille + ClockTrigger type = triggerType != null ? triggerType : ClockTrigger.NONE; + clock.setTriggerType(type); + clock.setTriggerRef(normalizeRef(type, triggerRef)); + clock.setFrontId(blankToNull(frontId)); + return clockRepository.save(clock); + } + + // ----- Avancement automatique (co-MJ) : le monde qui réagit ----- + + /** Un Fait vient de passer à vrai → avance les horloges liées (FLAG_SET + ce fait). */ + public void onFlagRaised(String playthroughId, String flagName) { + advanceMatching(playthroughId, ClockTrigger.FLAG_SET, flagName); + } + + /** Une quête vient de passer à COMPLETED → avance les horloges liées à cette quête. */ + public void onQuestCompleted(String playthroughId, String questId) { + advanceMatching(playthroughId, ClockTrigger.QUEST_COMPLETED, questId); + } + + /** Une séance vient de se clôturer → avance les horloges « fin de séance » de la Partie. */ + public void onSessionEnded(String playthroughId) { + advanceMatching(playthroughId, ClockTrigger.SESSION_ENDED, null); + } + + private void advanceMatching(String playthroughId, ClockTrigger type, String ref) { + if (playthroughId == null) return; + for (Clock c : clockRepository.findByPlaythroughId(playthroughId)) { + boolean match = c.getTriggerType() == type + && (type == ClockTrigger.SESSION_ENDED || java.util.Objects.equals(c.getTriggerRef(), ref)); + if (match && c.getFilled() < c.getSegments()) { + c.setFilled(c.getFilled() + 1); + clockRepository.save(c); + } + } + } + + private String normalizeRef(ClockTrigger type, String ref) { + boolean needsRef = type == ClockTrigger.FLAG_SET || type == ClockTrigger.QUEST_COMPLETED; + if (!needsRef || ref == null || ref.isBlank()) return null; + return ref.trim(); + } + + private String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } + + /** +1 segment (sans dépasser {@code segments}). */ + public Clock advance(String id) { + Clock clock = require(id); + if (clock.getFilled() < clock.getSegments()) clock.setFilled(clock.getFilled() + 1); + return clockRepository.save(clock); + } + + /** −1 segment (sans descendre sous 0). */ + public Clock regress(String id) { + Clock clock = require(id); + if (clock.getFilled() > 0) clock.setFilled(clock.getFilled() - 1); + return clockRepository.save(clock); + } + + public void delete(String id) { + if (!clockRepository.existsById(id)) { + throw new IllegalArgumentException("Horloge introuvable : " + id); + } + clockRepository.deleteById(id); + } + + private Clock require(String id) { + return clockRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Horloge introuvable : " + id)); + } + + private int clampSegments(int segments) { + if (segments < 1) return 1; + return Math.min(segments, MAX_SEGMENTS); + } +} diff --git a/core/src/main/java/com/loremind/application/playcontext/FrontService.java b/core/src/main/java/com/loremind/application/playcontext/FrontService.java new file mode 100644 index 0000000..7f5c378 --- /dev/null +++ b/core/src/main/java/com/loremind/application/playcontext/FrontService.java @@ -0,0 +1,75 @@ +package com.loremind.application.playcontext; + +import com.loremind.domain.playcontext.Clock; +import com.loremind.domain.playcontext.Front; +import com.loremind.domain.playcontext.ports.ClockRepository; +import com.loremind.domain.playcontext.ports.FrontRepository; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Service applicatif des Fronts (menaces regroupant des horloges) d'une Partie. + */ +@Service +public class FrontService { + + private final FrontRepository frontRepository; + private final ClockRepository clockRepository; + private final PlaythroughRepository playthroughRepository; + + public FrontService(FrontRepository frontRepository, ClockRepository clockRepository, + PlaythroughRepository playthroughRepository) { + this.frontRepository = frontRepository; + this.clockRepository = clockRepository; + this.playthroughRepository = playthroughRepository; + } + + public List getByPlaythrough(String playthroughId) { + return frontRepository.findByPlaythroughId(playthroughId); + } + + public Front create(String playthroughId, String name, String description) { + if (playthroughId == null || !playthroughRepository.existsById(playthroughId)) { + throw new IllegalArgumentException("Partie introuvable : " + playthroughId); + } + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Le nom du front est requis."); + } + int order = frontRepository.findByPlaythroughId(playthroughId).size(); + Front front = Front.builder() + .playthroughId(playthroughId) + .name(name.trim()) + .description(description) + .order(order) + .build(); + return frontRepository.save(front); + } + + public Front update(String id, String name, String description) { + Front front = require(id); + if (name != null && !name.isBlank()) front.setName(name.trim()); + front.setDescription(description); + return frontRepository.save(front); + } + + /** Supprime un front et ORPHELINE ses horloges (frontId -> null : on ne perd pas d'horloges). */ + @Transactional + public void delete(String id) { + Front front = require(id); + for (Clock c : clockRepository.findByPlaythroughId(front.getPlaythroughId())) { + if (id.equals(c.getFrontId())) { + c.setFrontId(null); + clockRepository.save(c); + } + } + frontRepository.deleteById(id); + } + + private Front require(String id) { + return frontRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Front introuvable : " + id)); + } +} diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionPrepReport.java b/core/src/main/java/com/loremind/application/playcontext/SessionPrepReport.java new file mode 100644 index 0000000..661e392 --- /dev/null +++ b/core/src/main/java/com/loremind/application/playcontext/SessionPrepReport.java @@ -0,0 +1,52 @@ +package com.loremind.application.playcontext; + +import com.loremind.application.campaigncontext.ReadinessGap; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Bilan de PRÉPARATION DE SÉANCE d'une Partie (Phase 3 co-MJ — « PlanDeSéance »). + * Read-model pur, calculé à la volée : croise la position des joueurs (quêtes en cours / + * disponibles, dernière séance), le contenu probable (nœuds des quêtes actives), les + * manques de guidage CIBLÉS sur ce contenu, et les horloges en mouvement. + * + * @param playthroughId la Partie évaluée + * @param lastSession dernière séance (ou {@code null} si aucune) + * @param questsInProgress quêtes EN COURS (le « où en sont les joueurs ») + * @param questsAvailable quêtes DISPONIBLES (les prochaines pistes probables) + * @param questsCompleted quêtes TERMINÉES (rappel discret + possibilité de rouvrir) + * @param hotspots chapitres / scènes probables (nœuds des quêtes actives, dédupliqués) + * @param gaps manques de guidage restreints au contenu probable (à combler avant de jouer) ; + * si la campagne n'utilise pas de quêtes, tous les manques de la campagne + * @param otherGapCount manques ailleurs dans la campagne (information, non bloquant pour la séance) + * @param clocks horloges entamées ({@code filled > 0}), avec le nom de leur menace + */ +public record SessionPrepReport( + String playthroughId, + LastSessionInfo lastSession, + List questsInProgress, + List questsAvailable, + List questsCompleted, + List hotspots, + List gaps, + int otherGapCount, + List clocks +) { + + /** Dernière séance tenue (ou en cours) de la Partie. */ + public record LastSessionInfo(String id, String name, LocalDateTime startedAt, + LocalDateTime endedAt, boolean active) {} + + /** Quête résumée (statut implicite par la liste qui la porte). */ + public record QuestInfo(String id, String name, String icon) {} + + /** + * Nœud narratif probable. {@code nodeType} = "CHAPTER"|"SCENE" ; {@code arcId}/ + * {@code chapterId} = contexte de navigation pour le lien profond côté front. + */ + public record NodeInfo(String nodeType, String id, String name, String arcId, String chapterId) {} + + /** Horloge en mouvement (le front affiche « presque pleine » quand il reste ≤ 1 segment). */ + public record ClockInfo(String id, String name, int segments, int filled, String frontName) {} +} diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionPrepService.java b/core/src/main/java/com/loremind/application/playcontext/SessionPrepService.java new file mode 100644 index 0000000..05e6089 --- /dev/null +++ b/core/src/main/java/com/loremind/application/playcontext/SessionPrepService.java @@ -0,0 +1,204 @@ +package com.loremind.application.playcontext; + +import com.loremind.application.campaigncontext.CampaignReadinessAssessment; +import com.loremind.application.campaigncontext.CampaignReadinessService; +import com.loremind.application.campaigncontext.QuestStatusEnricher; +import com.loremind.application.campaigncontext.ReadinessGap; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.domain.campaigncontext.QuestStatus; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import com.loremind.domain.playcontext.Front; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.ports.ClockRepository; +import com.loremind.domain.playcontext.ports.FrontRepository; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.domain.playcontext.ports.SessionRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Read-model « Préparer la prochaine séance » (Phase 3 co-MJ). Croise, pour UNE Partie : + * + *
    + *
  1. Position des joueurs — quêtes EN COURS / DISPONIBLES (statut effectif via + * {@link QuestStatusEnricher}, un seul snapshot) + dernière séance ;
  2. + *
  3. Contenu probable — les chapitres/scènes traversés par ces quêtes actives ;
  4. + *
  5. Manques ciblés — les gaps du guidage ({@link CampaignReadinessService}) + * restreints à ce contenu probable : « quoi combler AVANT la prochaine séance » ;
  6. + *
  7. Menaces en mouvement — horloges entamées, avec leur front.
  8. + *
+ * + *

Déterministe, sans IA, zéro persistance. Si la campagne n'utilise pas de quêtes, la + * notion de « contenu probable » n'existe pas : on renvoie alors TOUS les manques (toute + * la campagne est potentiellement la prochaine séance).

+ */ +@Service +public class SessionPrepService { + + private final PlaythroughRepository playthroughRepository; + private final SessionRepository sessionRepository; + private final ClockRepository clockRepository; + private final FrontRepository frontRepository; + private final QuestRepository questRepository; + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + private final QuestStatusEnricher statusEnricher; + private final CampaignReadinessService readinessService; + + public SessionPrepService(PlaythroughRepository playthroughRepository, + SessionRepository sessionRepository, + ClockRepository clockRepository, + FrontRepository frontRepository, + QuestRepository questRepository, + ChapterRepository chapterRepository, + SceneRepository sceneRepository, + QuestStatusEnricher statusEnricher, + CampaignReadinessService readinessService) { + this.playthroughRepository = playthroughRepository; + this.sessionRepository = sessionRepository; + this.clockRepository = clockRepository; + this.frontRepository = frontRepository; + this.questRepository = questRepository; + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + this.statusEnricher = statusEnricher; + this.readinessService = readinessService; + } + + public SessionPrepReport prepare(String playthroughId) { + Playthrough playthrough = playthroughRepository.findById(playthroughId) + .orElseThrow(() -> new IllegalArgumentException("Partie non trouvée: " + playthroughId)); + String campaignId = playthrough.getCampaignId(); + + // 1) Position : quêtes actives + dernière séance. + List quests = questRepository.findByCampaignId(campaignId); + Map statusById = statusEnricher.computeAll(quests, playthroughId); + + List inProgress = byStatus(quests, statusById, QuestStatus.IN_PROGRESS); + List available = byStatus(quests, statusById, QuestStatus.AVAILABLE); + List completed = byStatus(quests, statusById, QuestStatus.COMPLETED); + + // 2) Contenu probable : nœuds des quêtes actives (en cours d'abord), dédupliqués. + LinkedHashSet hotspotChapterIds = new LinkedHashSet<>(); + LinkedHashSet hotspotSceneIds = new LinkedHashSet<>(); + List hotspots = new ArrayList<>(); + for (Quest quest : concat(inProgress, available)) { + for (QuestNodeRef node : nullSafe(quest.getNodes())) { + resolveNode(node, hotspotChapterIds, hotspotSceneIds, hotspots); + } + } + + // 3) Manques ciblés sur ce contenu probable. Sans quête, tout est « probable ». + Set hotspotQuestIds = concat(inProgress, available).stream() + .map(Quest::getId).collect(Collectors.toSet()); + CampaignReadinessAssessment assessment = readinessService.assess(campaignId); + List focused; + if (quests.isEmpty()) { + focused = assessment.gaps(); + } else { + focused = assessment.gaps().stream() + .filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds)) + .collect(Collectors.toList()); + } + int otherGapCount = assessment.gaps().size() - focused.size(); + + // 4) Menaces en mouvement : horloges entamées, avec le nom de leur front. + Map frontNames = frontRepository.findByPlaythroughId(playthroughId).stream() + .collect(Collectors.toMap(Front::getId, Front::getName, (a, b) -> a)); + List clocks = clockRepository.findByPlaythroughId(playthroughId).stream() + .filter(c -> c.getFilled() > 0) + .map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(), + c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null)) + .collect(Collectors.toList()); + + return new SessionPrepReport( + playthroughId, + lastSessionOf(playthroughId), + toQuestInfos(inProgress), + toQuestInfos(available), + toQuestInfos(completed), + hotspots, + focused, + otherGapCount, + clocks); + } + + /** Résout un nœud de quête vers son info navigable ; les refs mortes sont ignorées. */ + private void resolveNode(QuestNodeRef node, + Set chapterIds, Set sceneIds, + List out) { + if (node == null || node.nodeId() == null || node.nodeType() == null) return; + switch (node.nodeType()) { + case CHAPTER -> chapterRepository.findById(node.nodeId()).ifPresent(ch -> { + if (chapterIds.add(ch.getId())) { + out.add(new SessionPrepReport.NodeInfo("CHAPTER", ch.getId(), ch.getName(), ch.getArcId(), ch.getId())); + } + }); + case SCENE -> sceneRepository.findById(node.nodeId()).ifPresent(sc -> { + if (sceneIds.add(sc.getId())) { + String arcId = chapterRepository.findById(sc.getChapterId()) + .map(Chapter::getArcId).orElse(null); + out.add(new SessionPrepReport.NodeInfo("SCENE", sc.getId(), sc.getName(), arcId, sc.getChapterId())); + } + }); + } + } + + /** Un gap est « ciblé » s'il touche une entité probable (ou une scène d'un chapitre probable). */ + private boolean isFocused(ReadinessGap gap, + Set chapterIds, Set sceneIds, Set questIds) { + return switch (gap.entityType()) { + case SCENE -> sceneIds.contains(gap.entityId()) + || (gap.chapterId() != null && chapterIds.contains(gap.chapterId())); + case CHAPTER -> chapterIds.contains(gap.entityId()); + case QUEST -> questIds.contains(gap.entityId()); + default -> false; + }; + } + + private SessionPrepReport.LastSessionInfo lastSessionOf(String playthroughId) { + List sessions = sessionRepository.findByPlaythroughId(playthroughId); + return sessions.stream() + .max(Comparator.comparing(Session::getStartedAt, + Comparator.nullsFirst(Comparator.naturalOrder()))) + .map(s -> new SessionPrepReport.LastSessionInfo( + s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive())) + .orElse(null); + } + + private static List byStatus(List quests, Map statusById, QuestStatus wanted) { + return quests.stream() + .filter(q -> statusById.get(q.getId()) == wanted) + .sorted(Comparator.comparingInt(Quest::getOrder)) + .collect(Collectors.toList()); + } + + private static List toQuestInfos(List quests) { + return quests.stream() + .map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon())) + .collect(Collectors.toList()); + } + + private static List concat(List a, List b) { + List out = new ArrayList<>(a); + out.addAll(b); + return out; + } + + private static List nullSafe(List list) { + return list != null ? list : List.of(); + } +} diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionRecapService.java b/core/src/main/java/com/loremind/application/playcontext/SessionRecapService.java new file mode 100644 index 0000000..f4d4f32 --- /dev/null +++ b/core/src/main/java/com/loremind/application/playcontext/SessionRecapService.java @@ -0,0 +1,85 @@ +package com.loremind.application.playcontext; + +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.SessionEntry; +import com.loremind.domain.playcontext.ports.SessionEntryRepository; +import com.loremind.domain.playcontext.ports.SessionRecapAssistant; +import com.loremind.domain.playcontext.ports.SessionRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * Génère le récap « précédemment dans… » à lire à l'OUVERTURE d'une séance : résume le + * journal de la SÉANCE PRÉCÉDENTE (même Partie, la plus récente commencée avant celle-ci ; + * ou la dernière terminée si la courante est la plus récente). Zéro persistance : le MJ + * lit le récap, et peut choisir côté front de le consigner au journal. + */ +@Service +public class SessionRecapService { + + /** Plafond du transcript envoyé au LLM (garde le budget de contexte local ~16k). */ + private static final int MAX_TRANSCRIPT_CHARS = 12_000; + + private final SessionRepository sessionRepository; + private final SessionEntryRepository entryRepository; + private final SessionRecapAssistant recapAssistant; + + public SessionRecapService(SessionRepository sessionRepository, + SessionEntryRepository entryRepository, + SessionRecapAssistant recapAssistant) { + this.sessionRepository = sessionRepository; + this.entryRepository = entryRepository; + this.recapAssistant = recapAssistant; + } + + /** Récap généré + nom de la séance résumée (pour l'afficher au MJ). */ + public record RecapResult(String previousSessionName, String recap) {} + + public RecapResult recapPreviousSession(String sessionId) { + Session current = sessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId)); + + Session previous = findPreviousSession(current) + .orElseThrow(() -> new IllegalArgumentException( + "Aucune séance précédente à résumer pour cette Partie.")); + + List entries = entryRepository.findBySessionId(previous.getId()); + if (entries.isEmpty()) { + throw new IllegalArgumentException( + "La séance précédente (« " + previous.getName() + " ») n'a aucune entrée de journal à résumer."); + } + + String transcript = buildTranscript(entries); + String context = "Séance : " + previous.getName(); + return new RecapResult(previous.getName(), recapAssistant.generateRecap(transcript, context)); + } + + /** Séance la plus récente de la même Partie commencée AVANT la courante. */ + private Optional findPreviousSession(Session current) { + LocalDateTime pivot = current.getStartedAt(); + return sessionRepository.findByPlaythroughId(current.getPlaythroughId()).stream() + .filter(s -> !s.getId().equals(current.getId())) + .filter(s -> s.getStartedAt() != null + && (pivot == null || s.getStartedAt().isBefore(pivot))) + .max(Comparator.comparing(Session::getStartedAt)); + } + + /** Journal chronologique, une ligne par entrée, plafonné (on garde la FIN — le dénouement). */ + private static String buildTranscript(List entries) { + StringBuilder sb = new StringBuilder(); + for (SessionEntry e : entries) { + if (e.getContent() == null || e.getContent().isBlank()) continue; + sb.append("[").append(e.getType() != null ? e.getType().name() : "NOTE").append("] ") + .append(e.getContent().trim()).append("\n"); + } + String transcript = sb.toString(); + if (transcript.length() > MAX_TRANSCRIPT_CHARS) { + transcript = "…\n" + transcript.substring(transcript.length() - MAX_TRANSCRIPT_CHARS); + } + return transcript; + } +} diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionService.java b/core/src/main/java/com/loremind/application/playcontext/SessionService.java index 2306e45..c937fa5 100644 --- a/core/src/main/java/com/loremind/application/playcontext/SessionService.java +++ b/core/src/main/java/com/loremind/application/playcontext/SessionService.java @@ -27,13 +27,16 @@ public class SessionService { private final SessionRepository sessionRepository; private final SessionEntryRepository entryRepository; private final PlaythroughRepository playthroughRepository; + private final ClockService clockService; public SessionService(SessionRepository sessionRepository, SessionEntryRepository entryRepository, - PlaythroughRepository playthroughRepository) { + PlaythroughRepository playthroughRepository, + ClockService clockService) { this.sessionRepository = sessionRepository; this.entryRepository = entryRepository; this.playthroughRepository = playthroughRepository; + this.clockService = clockService; } /** @@ -70,6 +73,21 @@ public class SessionService { throw new IllegalStateException("Cette session est déjà terminée."); } session.setEndedAt(LocalDateTime.now()); + Session saved = sessionRepository.save(session); + // Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie. + clockService.onSessionEnded(saved.getPlaythroughId()); + return saved; + } + + /** + * Épingle (ou dés-épingle avec {@code null}) la scène courante de la session — mode + * cockpit : « on en est là ». Weak ref : on ne valide pas l'existence de la scène + * (l'UI ne propose que des scènes réelles ; une scène supprimée rend l'épingle caduque). + */ + public Session setCurrentScene(String id, String sceneId) { + Session session = sessionRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id)); + session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null); return sessionRepository.save(session); } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java b/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java index 65ddf10..f4266fb 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java @@ -3,12 +3,17 @@ package com.loremind.domain.campaigncontext; /** * Type structurel d'un Arc. * - LINEAR : narration séquentielle classique (chapitres joués dans l'ordre). - * - HUB : narration non linéaire ; les chapitres sont des "quêtes" satellites - * potentiellement parallèles, soumises à des prérequis pour être débloquées. + * - HUB : narration non linéaire ; contient des QUÊTES (entités Quest rattachées). + * - SYSTEM : arc TECHNIQUE (« Quêtes libres », un par campagne au besoin) qui héberge + * les conteneurs de scènes des quêtes LIBRES (hors arc). Masqué de la + * narration dans l'arbre (ses quêtes s'affichent sous « Quêtes ») ; dans + * les exports (PDF / Foundry / backup) il apparaît sous son nom — ses + * scènes sont du vrai contenu jouable. * * Value Object du domaine (Bounded Context : Campaign). */ public enum ArcType { LINEAR, - HUB + HUB, + SYSTEM } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java index 9397ce9..e3ddd92 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java @@ -21,14 +21,6 @@ public class Chapter { private String arcId; // Référence vers l'Arc parent private int order; // Ordre du chapitre dans l'arc - /** - * Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE. - * Pertinent surtout pour les chapitres d'un Arc HUB ; ignoré pour LINEAR. - * Donnée de SCÉNARIO — partagée par toutes les Parties de la campagne. - */ - @Builder.Default - private List prerequisites = new ArrayList<>(); - /** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */ private String icon; diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/EntityFieldPatchProposal.java b/core/src/main/java/com/loremind/domain/campaigncontext/EntityFieldPatchProposal.java new file mode 100644 index 0000000..c935c01 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/EntityFieldPatchProposal.java @@ -0,0 +1,21 @@ +package com.loremind.domain.campaigncontext; + +import java.util.List; + +/** + * Proposition de patch champ-par-champ d'une entité de scénario (Pilier A — co-création + * « propose → applique »). Éphémère et non persistée (comme {@code CampaignImportProposal}). + * + *

Le front affiche chaque {@link FieldProposal} (diff avant/après), l'utilisateur + * accepte/rejette champ par champ, puis renvoie CE MÊME record filtré aux seuls champs + * acceptés au endpoint d'application. Aucun statut d'acceptation n'est porté ici : le grain + * d'acceptation vit côté UI, l'apply ne reçoit que les champs retenus.

+ * + * @param target type d'entité ciblée : {@code "scene"} (tranche 1), à terme aussi + * {@code "arc"|"chapter"|"npc"} + * @param targetId id de l'entité DÉJÀ persistée à patcher + * @param type {@code "patch"} (tranche 1) ou {@code "create"} (extension future) + * @param fields un {@link FieldProposal} par champ proposé/accepté + */ +public record EntityFieldPatchProposal(String target, String targetId, String type, List fields) { +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/FieldProposal.java b/core/src/main/java/com/loremind/domain/campaigncontext/FieldProposal.java new file mode 100644 index 0000000..c45c914 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/FieldProposal.java @@ -0,0 +1,16 @@ +package com.loremind.domain.campaigncontext; + +/** + * Proposition IA pour UN champ textuel d'une entité (Pilier A — co-création). + * + *

Value Object immuable, non persisté (éphémère, comme les records {@code *Proposal} + * de l'import). La {@code key} est alignée sur les champs exposés par + * {@code NarrativeEntityContextBuilder.fromScene()} : c'est la source de vérité qui relie + * la génération, l'affichage (diff avant/après) et l'application (patch ciblé).

+ * + * @param key clé du champ (ex. {@code "playerNarration"}, {@code "atmosphere"}) + * @param currentValue valeur actuelle du champ (echo pour la diff UI ; ignorée à l'apply) + * @param proposedValue valeur proposée par l'IA pour ce champ + */ +public record FieldProposal(String key, String currentValue, String proposedValue) { +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/LinkType.java b/core/src/main/java/com/loremind/domain/campaigncontext/LinkType.java new file mode 100644 index 0000000..dd84f62 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/LinkType.java @@ -0,0 +1,17 @@ +package com.loremind.domain.campaigncontext; + +/** + * Type d'un lien narratif entre nœuds ({@link SceneBranch}) — Niveau 2. + * + *

{@code EXIT} = sortie / choix narratif : c'est la sémantique historique des + * branches, et la valeur par défaut (branches existantes / bundles antérieurs). + * {@code CLUE} et {@code LEAD} enrichissent le graphe façon « Three Clue Rule ».

+ */ +public enum LinkType { + /** Sortie / choix narratif (défaut, comportement historique). */ + EXIT, + /** Indice menant à une information. */ + CLUE, + /** Piste vers un autre nœud. */ + LEAD +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/NodeType.java b/core/src/main/java/com/loremind/domain/campaigncontext/NodeType.java new file mode 100644 index 0000000..95ef33b --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/NodeType.java @@ -0,0 +1,14 @@ +package com.loremind.domain.campaigncontext; + +/** + * Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}. + * + *

Une quête est ORTHOGONALE à l'arbre Arc→Chapitre→Scène : elle peut pointer + * un chapitre entier (CHAPTER) ou une scène précise (SCENE), et traverser + * plusieurs nœuds. Le schéma supporte les deux dès le départ (décision D3) ; + * l'UI démarre sur les chapitres.

+ */ +public enum NodeType { + CHAPTER, + SCENE +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Quest.java b/core/src/main/java/com/loremind/domain/campaigncontext/Quest.java new file mode 100644 index 0000000..f878774 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Quest.java @@ -0,0 +1,67 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Entité de domaine (Aggregate Root) représentant une Quête. + * + *

Niveau 1 : la Quête est une entité de PREMIÈRE CLASSE, ORTHOGONALE à + * l'arbre Arc→Chapitre→Scène. Elle est rattachée à la Campagne (décision D2) et + * référence des nœuds narratifs arbitraires via {@link QuestNodeRef}. Elle porte + * ses propres conditions de déblocage (réutilise le sealed {@link Prerequisite}).

+ * + *

Elle remplace le double rôle historique du {@code Chapter} en mode HUB : + * {@code Prerequisite.QuestCompleted.questId} et {@code QuestProgression.questId} + * pointent désormais une {@code Quest.id}. Entité pure, sans dépendance technique.

+ */ +@Data +@Builder +public class Quest { + + private String id; + private String campaignId; // Rattachement campagne (orthogonalité, décision D2) + /** + * Arc de rattachement (weak ref, NULLABLE). Non nul ⇒ quête d'un ARC HUB (affichée + * sous cet arc). Null ⇒ quête TRANSVERSE (peut couvrir plusieurs arcs ; visible dans + * la liste « Quêtes »). Rétro-compat : les quêtes existantes ont {@code arcId=null}. + */ + private String arcId; + private String name; + private String description; // Synopsis de la quête + private String icon; // Clé d'icône (cf. CAMPAIGN_ICON_OPTIONS côté front) + private int order; // Ordre d'affichage dans la campagne + + /** + * Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE. + * Réutilise le sealed {@link Prerequisite} (migré depuis Chapter en mode HUB). + * Donnée de SCÉNARIO — l'état réel par Partie vit dans QuestProgression (Play Context). + */ + @Builder.Default + private List prerequisites = new ArrayList<>(); + + /** + * Nœuds narratifs (Chapitres / Scènes) traversés par la quête (N‑N, weak refs). + */ + @Builder.Default + private List nodes = new ArrayList<>(); + + // Champs narratifs (repris de l'usage HUB du Chapter) + private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT) + private String playerObjectives; // Objectifs des joueurs pour cette quête + private String narrativeStakes; // Enjeux narratifs dramatiques + + /** IDs des pages du Lore associées (weak cross-context references). */ + @Builder.Default + private List relatedPageIds = new ArrayList<>(); + + /** IDs des images (Shared Kernel) illustrant la quête. */ + @Builder.Default + private List illustrationImageIds = new ArrayList<>(); + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/QuestNodeRef.java b/core/src/main/java/com/loremind/domain/campaigncontext/QuestNodeRef.java new file mode 100644 index 0000000..b9c9a42 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/QuestNodeRef.java @@ -0,0 +1,17 @@ +package com.loremind.domain.campaigncontext; + +/** + * Value Object : lien faible d'une {@link Quest} vers un nœud narratif + * (Chapitre ou Scène) qu'elle traverse. + * + *

Référence faible (pas de FK dure cross-aggregate, cohérent avec + * {@code relatedPageIds}) : une quête peut couvrir plusieurs nœuds et un nœud + * peut servir plusieurs quêtes (N‑N). Immuable (record), comme + * {@link SceneBranch} / {@link RoomBranch}.

+ * + * @param nodeType type du nœud cible (CHAPTER|SCENE) + * @param nodeId id du Chapter ou de la Scene (weak ref) + * @param order ordre d'affichage du nœud dans la quête + */ +public record QuestNodeRef(NodeType nodeType, String nodeId, int order) { +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessEntityType.java b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessEntityType.java new file mode 100644 index 0000000..7c69598 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessEntityType.java @@ -0,0 +1,16 @@ +package com.loremind.domain.campaigncontext; + +/** + * Type de l'entité de scénario ciblée par un manque de readiness. Le front s'en + * sert (avec {@code arcId}/{@code chapterId} du gap) pour construire le lien profond + * vers l'éditeur concerné. + */ +public enum ReadinessEntityType { + CAMPAIGN, + ARC, + CHAPTER, + SCENE, + QUEST, + NPC, + ENEMY +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessSeverity.java b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessSeverity.java new file mode 100644 index 0000000..a5cd93c --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessSeverity.java @@ -0,0 +1,19 @@ +package com.loremind.domain.campaigncontext; + +/** + * Gravité d'un manque de préparation détecté par le guidage (Pilier B). + * + *

Purement indicatif : aucun niveau ne BLOQUE une action. Le guidage conseille, + * il n'interdit rien (un MJ qui improvise n'est jamais empêché).

+ */ +public enum ReadinessSeverity { + + /** Empêche de jouer proprement : structure cassée, entité vide, référence morte. */ + BLOCKING, + + /** Fortement conseillé pour la cohérence / le confort de jeu (ex. combat sans ennemi). */ + RECOMMENDED, + + /** Finition (ambiance, illustrations…). Masqué par défaut dans l'UI. */ + OPTIONAL +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessStatus.java new file mode 100644 index 0000000..0321b61 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessStatus.java @@ -0,0 +1,22 @@ +package com.loremind.domain.campaigncontext; + +/** + * Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »). + * + *

À NE PAS confondre avec la progression EN PARTIE ({@link ProgressionStatus} / + * {@link QuestStatus}, Play Context). Le readiness décrit « ce scénario est-il prêt + * à jouer ? » et se calcule uniquement depuis les champs du Campaign Context — + * jamais depuis un Playthrough. Ordre implicite DRAFT < PLAYABLE < POLISHED, + * agrégé vers le haut en MIN() (le maillon faible tire l'ensemble vers le bas).

+ */ +public enum ReadinessStatus { + + /** Il reste au moins un manque BLOQUANT (structure cassée, vide, référence morte). */ + DRAFT, + + /** Plus aucun manque bloquant : jouable, mais il reste des manques recommandés. */ + PLAYABLE, + + /** Plus aucun manque bloquant ni recommandé : prêt « clé en main ». */ + POLISHED +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java index 9b9419d..6740078 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java @@ -24,6 +24,14 @@ public class Scene { /** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */ private String icon; + /** + * Type narratif du nœud (Niveau 2 — graphe de nœuds typés). Défaut + * {@link SceneType#GENERIC} (scène non typée). Métadonnée : n'altère pas + * le comportement existant des scènes. + */ + @Builder.Default + private SceneType type = SceneType.GENERIC; + // === Contexte et ambiance === private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or) private String timing; // Moment (ex: Soir, à la tombée de la nuit) @@ -65,16 +73,23 @@ public class Scene { private List illustrationImageIds = new ArrayList<>(); /** - * "Battlemap" de la scene destinee a l'export Foundry : paire { media + sidecar }. - * Le media est un fichier image ou video ({@link com.loremind.domain.files.StoredFile}), - * le sidecar le .json/.dd2vtt Universal VTT (grille, murs, portes, lumieres) sorti - * de Dungeon Alchemist / Dungeondraft. Non affichee dans l'appli : passee telle quelle - * a l'export pour recreer la Scene cote Foundry. Null = scene sans carte. + * "Battlemaps" de la scene destinees a l'export Foundry : liste de paires + * { media + sidecar } etiquetees ({@link SceneBattlemap}). Plusieurs cartes = + * variantes de la meme scene (Jour/Nuit, etages, avant/apres). Le media est un + * fichier image ou video ({@link com.loremind.domain.files.StoredFile}), le sidecar + * le .json/.dd2vtt Universal VTT (grille, murs, portes, lumieres) sorti de + * Dungeon Alchemist / Dungeondraft. Non affichees dans l'appli : passees telles + * quelles a l'export pour recreer les Scenes cote Foundry. Vide = scene sans carte. */ - private String battlemapMediaFileId; + @Builder.Default + private List battlemaps = new ArrayList<>(); - /** ID du fichier sidecar Universal VTT (json) associe au media. Null si absent. */ - private String battlemapDataFileId; + /** + * Position X/Y du nœud dans la vue graphe du chapitre (Niveau 2). {@code null} = + * pas encore positionné → le layout automatique (BFS) place le nœud à l'ouverture. + */ + private Double graphX; + private Double graphY; /** * Sorties narratives possibles depuis cette scène (graphe intra-chapitre). diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBattlemap.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBattlemap.java new file mode 100644 index 0000000..6e7bea6 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBattlemap.java @@ -0,0 +1,23 @@ +package com.loremind.domain.campaigncontext; + +/** + * Value Object représentant UNE battlemap d'une Scene destinée à l'export Foundry : + * paire { media + sidecar Universal VTT } plus un libellé libre. Une scène peut en + * porter plusieurs (variantes « Jour » / « Nuit », étages, états avant/après…). + *

+ * Record Java : immuable, sans dépendance technique (même pattern que + * {@link SceneBranch}) — Jackson le (dé)sérialise nativement via le constructeur + * canonique pour le stockage JSON en base. + * + * @param label Libellé de la variante (ex : "Jour", "Nuit"). Jamais null (normalisé ""). + * @param mediaFileId ID du fichier media image/video ({@link com.loremind.domain.files.StoredFile}). + * Null = carte sans fond (sidecar seul). + * @param dataFileId ID du fichier sidecar Universal VTT (.json/.dd2vtt). Null si absent. + */ +public record SceneBattlemap(String label, String mediaFileId, String dataFileId) { + + /** Normalise un libellé absent en chaîne vide (une seule représentation du « sans nom »). */ + public SceneBattlemap { + if (label == null) label = ""; + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java index 186751c..ff61d45 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java @@ -15,8 +15,19 @@ package com.loremind.domain.campaigncontext; * @param label Libellé du choix (ex: "Si les joueurs attaquent le garde"). * @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement. * @param condition Notes MJ privées sur la condition de déclenchement (optionnel). + * @param kind Type de lien (Niveau 2). {@code null} normalisé en {@link LinkType#EXIT}. */ -public record SceneBranch(String label, String targetSceneId, String condition) { +public record SceneBranch(String label, String targetSceneId, String condition, LinkType kind) { + + /** Normalise un {@code kind} absent (branches / bundles antérieurs au Niveau 2) vers EXIT. */ + public SceneBranch { + if (kind == null) kind = LinkType.EXIT; + } + + /** Constructeur 3-args rétro-compatible : {@code kind} par défaut {@link LinkType#EXIT}. */ + public SceneBranch(String label, String targetSceneId, String condition) { + this(label, targetSceneId, condition, LinkType.EXIT); + } /** Raccourci pour construire une branche sans condition (cas le plus courant). */ public static SceneBranch of(String label, String targetSceneId) { diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraft.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraft.java new file mode 100644 index 0000000..97731a5 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraft.java @@ -0,0 +1,13 @@ +package com.loremind.domain.campaigncontext; + +/** + * Ébauche de scène proposée par l'IA (Pilier A — capacité « create »). Value Object + * immuable et non persisté : l'utilisateur révise la liste, puis les ébauches ACCEPTÉES + * sont créées comme vraies {@link Scene} dans le chapitre ciblé. + * + * @param name titre court de la scène (obligatoire à la création) + * @param description résumé bref (facultatif) + * @param playerNarration texte de mise en scène lu aux joueurs (facultatif) + */ +public record SceneDraft(String name, String description, String playerNarration) { +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraftProposal.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraftProposal.java new file mode 100644 index 0000000..77ca4ec --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraftProposal.java @@ -0,0 +1,14 @@ +package com.loremind.domain.campaigncontext; + +import java.util.List; + +/** + * Proposition de PEUPLEMENT d'un chapitre en scènes (Pilier A — capacité « create »). + * Éphémère : le front affiche les ébauches, l'utilisateur accepte/rejette, puis renvoie + * ce record filtré aux ébauches retenues au endpoint d'application (qui crée les scènes). + * + * @param chapterId chapitre cible où créer les scènes + * @param scenes ébauches proposées / retenues + */ +public record SceneDraftProposal(String chapterId, List scenes) { +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneType.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneType.java new file mode 100644 index 0000000..b80cc26 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneType.java @@ -0,0 +1,23 @@ +package com.loremind.domain.campaigncontext; + +/** + * Type narratif d'un nœud (Scène) — Niveau 2 (graphe de nœuds typés). + * + *

Métadonnée qui oriente le rendu et l'édition dans la vue graphe ; elle + * n'altère pas le comportement existant des scènes. {@code GENERIC} = scène non + * typée (valeur par défaut, et celle des scènes antérieures au Niveau 2).

+ */ +public enum SceneType { + /** Scène non typée (défaut). */ + GENERIC, + /** Un lieu à explorer. */ + LOCATION, + /** Une rencontre / un combat. */ + ENCOUNTER, + /** Une situation centrée sur un PNJ. */ + NPC, + /** Un événement scénarisé. */ + EVENT, + /** Une révélation / un indice majeur. */ + REVELATION +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NarrativeAssistException.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NarrativeAssistException.java new file mode 100644 index 0000000..935c0d5 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NarrativeAssistException.java @@ -0,0 +1,16 @@ +package com.loremind.domain.campaigncontext.ports; + +/** + * Erreur de génération lors de l'étoffage IA d'une entité narrative (Brain injoignable, + * réponse inexploitable…). Traduite en HTTP 502 par le controller. + */ +public class NarrativeAssistException extends RuntimeException { + + public NarrativeAssistException(String message) { + super(message); + } + + public NarrativeAssistException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NarrativeFieldAssistant.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NarrativeFieldAssistant.java new file mode 100644 index 0000000..0e6fcf4 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NarrativeFieldAssistant.java @@ -0,0 +1,32 @@ +package com.loremind.domain.campaigncontext.ports; + +import java.util.List; + +/** + * Port de sortie (Pilier A — co-création) : demande à l'IA d'ÉTOFFER les champs d'une + * entité narrative (arc, chapitre ou scène). Implémenté par un client du Brain. One-shot. + * + *

Générique par {@code entityType} : le Core est la SOURCE DE VÉRITÉ des champs + * (clé + libellé), le Brain ne fait que rédiger. Double garde-fou : whitelist stricte + * de clés côté Core ET côté adapter.

+ */ +public interface NarrativeFieldAssistant { + + /** Spécification d'un champ à proposer : clé technique + libellé lisible (pour le prompt). */ + record FieldSpec(String key, String label) {} + + /** Un champ proposé par l'IA (clé appartenant à la whitelist fournie). */ + record ProposedField(String key, String value) {} + + /** + * Génère des propositions de valeurs pour les champs d'une entité narrative. + * + * @param entityType {@code "arc"|"chapter"|"scene"} (pour la formulation du prompt) + * @param context contexte narratif compact (état actuel de l'entité + campagne) + * @param instruction consigne libre optionnelle du MJ (peut être vide/nulle) + * @param fields champs autorisés (whitelist) avec leur libellé + * @return champs proposés (uniquement des clés autorisées, valeurs non vides) ; vide + * si l'IA n'a rien de pertinent à proposer + */ + List assist(String entityType, String context, String instruction, List fields); +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/QuestRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/QuestRepository.java new file mode 100644 index 0000000..ce22d84 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/QuestRepository.java @@ -0,0 +1,27 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.Quest; +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des Quests. + * Interface définie dans le domaine, implémentée par l'infrastructure. + */ +public interface QuestRepository { + + Quest save(Quest quest); + + Optional findById(String id); + + List findByCampaignId(String campaignId); + + /** Quêtes rattachées à un arc (HUB). Vide si aucune. */ + List findByArcId(String arcId); + + List findAll(); + + void deleteById(String id); + + boolean existsById(String id); +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/SceneDraftAssistant.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/SceneDraftAssistant.java new file mode 100644 index 0000000..9df522f --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/SceneDraftAssistant.java @@ -0,0 +1,22 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.SceneDraft; + +import java.util.List; + +/** + * Port de sortie (Pilier A — capacité « create ») : demande à l'IA d'ÉBAUCHER des scènes + * pour un chapitre. Implémenté par un client du Brain. One-shot (pas de streaming). + */ +public interface SceneDraftAssistant { + + /** + * Génère des ébauches de scènes cohérentes avec le contexte fourni. + * + * @param context contexte narratif compact (chapitre + campagne + scènes existantes) + * @param instruction consigne libre optionnelle du MJ (peut être vide/nulle) + * @param count nombre de scènes souhaité (indicatif) + * @return ébauches proposées (titres non vides) ; vide si rien de pertinent + */ + List draftScenes(String context, String instruction, int count); +} diff --git a/core/src/main/java/com/loremind/domain/playcontext/Clock.java b/core/src/main/java/com/loremind/domain/playcontext/Clock.java new file mode 100644 index 0000000..498cfde --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/Clock.java @@ -0,0 +1,55 @@ +package com.loremind.domain.playcontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Horloge de progression (Clock, façon Blades in the Dark) — état dynamique de partie. + * + *

Appartient à un {@link Playthrough}. Compteur à {@code segments} segments dont + * {@code filled} sont remplis ; pleine ({@code filled == segments}) → un effet narratif + * se déclenche. Orthogonale à l'arbre Arc/Chapitre/Scène, comme {@code Session} / + * {@code QuestProgression} : c'est de l'état de Partie, pas du scénario.

+ */ +@Data +@Builder +public class Clock { + + private String id; + + /** Weak reference vers le Playthrough parent. */ + private String playthroughId; + + private String name; + + /** Ce qui se passe quand l'horloge est pleine (optionnel). */ + private String description; + + /** Nombre total de segments (≥ 1). */ + private int segments; + + /** Segments remplis (borné à [0, segments]). */ + private int filled; + + /** Ordre d'affichage parmi les horloges de la Partie. */ + private int order; + + /** Déclencheur d'avancement automatique (co-MJ). Défaut {@link ClockTrigger#NONE}. */ + @Builder.Default + private ClockTrigger triggerType = ClockTrigger.NONE; + + /** Cible du déclencheur : nom du fait (FLAG_SET) ou id de quête (QUEST_COMPLETED) ; sinon null. */ + private String triggerRef; + + /** Front (menace) auquel l'horloge appartient, ou null (horloge libre). Weak reference. */ + private String frontId; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public boolean isComplete() { + return filled >= segments; + } +} diff --git a/core/src/main/java/com/loremind/domain/playcontext/ClockTrigger.java b/core/src/main/java/com/loremind/domain/playcontext/ClockTrigger.java new file mode 100644 index 0000000..55b30c5 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/ClockTrigger.java @@ -0,0 +1,17 @@ +package com.loremind.domain.playcontext; + +/** + * Déclencheur d'avancement automatique d'une {@link Clock} (co-MJ) : l'horloge + * avance d'un segment quand l'événement lié survient dans la Partie. Le MJ garde + * la main (il peut reculer). {@code triggerRef} précise la cible selon le type. + */ +public enum ClockTrigger { + /** Aucun : l'horloge n'avance qu'à la main. */ + NONE, + /** Quand un Fait (flag) passe à vrai — {@code triggerRef} = nom du fait. */ + FLAG_SET, + /** Quand une quête passe à COMPLETED — {@code triggerRef} = id de la quête. */ + QUEST_COMPLETED, + /** À la clôture d'une séance de la Partie — pas de {@code triggerRef}. */ + SESSION_ENDED +} diff --git a/core/src/main/java/com/loremind/domain/playcontext/Front.java b/core/src/main/java/com/loremind/domain/playcontext/Front.java new file mode 100644 index 0000000..ab5e0ca --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/Front.java @@ -0,0 +1,31 @@ +package com.loremind.domain.playcontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Front (menace) d'une Partie : regroupement nommé d'horloges de progression + * ({@link Clock}) sous une même menace (ex. « La montée du Culte »). État de + * Partie, comme les horloges — orthogonal à l'arbre Arc/Chapitre/Scène. + */ +@Data +@Builder +public class Front { + + private String id; + + /** Weak reference vers le Playthrough parent. */ + private String playthroughId; + + private String name; + + private String description; + + /** Ordre d'affichage parmi les fronts de la Partie. */ + private int order; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java b/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java index 0b79f0b..10b8d25 100644 --- a/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java +++ b/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java @@ -11,7 +11,7 @@ import lombok.Data; * le scénario et l'état de jeu : ici, la progression est exclusivement * propre à une instance jouée (Playthrough).

* - *

Référence le Chapter par weak reference (chapterId) pour respecter les + *

Référence la Quest par weak reference (questId) pour respecter les * Bounded Contexts. Le type {@link ProgressionStatus} reste défini dans * Campaign Context (c'est un Value Object générique, partageable).

* @@ -24,6 +24,6 @@ public class QuestProgression { private String id; private String playthroughId; - private String chapterId; + private String questId; private ProgressionStatus status; } diff --git a/core/src/main/java/com/loremind/domain/playcontext/Session.java b/core/src/main/java/com/loremind/domain/playcontext/Session.java index 2391fd1..f83a937 100644 --- a/core/src/main/java/com/loremind/domain/playcontext/Session.java +++ b/core/src/main/java/com/loremind/domain/playcontext/Session.java @@ -31,6 +31,12 @@ public class Session { /** Null = session en cours ; renseigné = session terminée. */ private LocalDateTime endedAt; + /** + * Scène courante épinglée pendant la séance (mode cockpit). Weak ref nullable vers + * une Scene du scénario ; null = rien d'épinglé. Sert de repère « on en est là ». + */ + private String currentSceneId; + private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/ClockRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/ClockRepository.java new file mode 100644 index 0000000..85934da --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/ports/ClockRepository.java @@ -0,0 +1,23 @@ +package com.loremind.domain.playcontext.ports; + +import com.loremind.domain.playcontext.Clock; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des Horloges de progression (Clocks). + */ +public interface ClockRepository { + + Clock save(Clock clock); + + Optional findById(String id); + + /** Horloges d'une Partie, triées par {@code order} croissant. */ + List findByPlaythroughId(String playthroughId); + + void deleteById(String id); + + boolean existsById(String id); +} diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/FrontRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/FrontRepository.java new file mode 100644 index 0000000..e6bebca --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/ports/FrontRepository.java @@ -0,0 +1,23 @@ +package com.loremind.domain.playcontext.ports; + +import com.loremind.domain.playcontext.Front; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des Fronts (menaces regroupant des horloges). + */ +public interface FrontRepository { + + Front save(Front front); + + Optional findById(String id); + + /** Fronts d'une Partie, triés par {@code order} croissant. */ + List findByPlaythroughId(String playthroughId); + + void deleteById(String id); + + boolean existsById(String id); +} diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java index 1409e87..2cb5b2b 100644 --- a/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java +++ b/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java @@ -17,15 +17,18 @@ public interface QuestProgressionRepository { /** Liste toutes les progressions explicites d'un Playthrough. */ List findByPlaythroughId(String playthroughId); - /** Set des IDs de chapitres en COMPLETED pour un Playthrough donné (fast path éval). */ - Set findCompletedChapterIdsByPlaythroughId(String playthroughId); + /** Set des IDs de quêtes en COMPLETED pour un Playthrough donné (fast path éval). */ + Set findCompletedQuestIdsByPlaythroughId(String playthroughId); /** * Crée ou met à jour le statut d'une quête pour un Playthrough. * Si {@code status == NOT_STARTED}, la ligne est supprimée (sémantique "absence"). */ - void setStatus(String playthroughId, String chapterId, ProgressionStatus status); + void setStatus(String playthroughId, String questId, ProgressionStatus status); /** Supprime toutes les progressions d'un Playthrough (cascade applicative). */ void deleteAllByPlaythroughId(String playthroughId); + + /** Supprime toutes les progressions d'une quête (cascade à la suppression d'une Quest). */ + void deleteByQuestId(String questId); } diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRecapAssistant.java b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRecapAssistant.java new file mode 100644 index 0000000..871281e --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRecapAssistant.java @@ -0,0 +1,18 @@ +package com.loremind.domain.playcontext.ports; + +/** + * Port de sortie (mode séance) : génère le récapitulatif « précédemment… » d'une séance + * à partir de son journal. Implémenté par un client du Brain. One-shot, texte libre. + */ +public interface SessionRecapAssistant { + + /** + * Résume le journal d'une séance en un récap à lire aux joueurs à l'ouverture + * de la séance suivante. + * + * @param transcript entrées du journal, chronologiques, une par ligne + * @param context méta courte (nom de la campagne / de la séance), peut être vide + * @return le récap rédigé (jamais null ; peut être court si le journal est maigre) + */ + String generateRecap(String transcript, String context); +} diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRecapException.java b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRecapException.java new file mode 100644 index 0000000..99fbbfd --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRecapException.java @@ -0,0 +1,16 @@ +package com.loremind.domain.playcontext.ports; + +/** + * Erreur de génération du récap de séance (Brain injoignable, réponse vide…). + * Traduite en HTTP 502 par le controller. + */ +public class SessionRecapException extends RuntimeException { + + public SessionRecapException(String message) { + super(message); + } + + public SessionRecapException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainNarrativeFieldClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainNarrativeFieldClient.java new file mode 100644 index 0000000..e31fac1 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainNarrativeFieldClient.java @@ -0,0 +1,95 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.campaigncontext.ports.NarrativeAssistException; +import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Adapter de sortie : étoffe les champs d'une entité narrative via le Brain (POST one-shot). + * Générique par {@code entityType}. Le Core envoie les champs AVEC leur libellé (source de + * vérité) ; double garde-fou : on ne retient que les clés autorisées, valeurs non vides. + */ +@Component +public class BrainNarrativeFieldClient implements NarrativeFieldAssistant { + + private static final String GENERATE_PATH = "/generate/narrative-fields"; + + private final RestTemplate restTemplate; + private final String baseUrl; + + public BrainNarrativeFieldClient( + RestTemplate restTemplate, + @Value("${brain.base-url}") String baseUrl) { + this.restTemplate = restTemplate; + this.baseUrl = baseUrl; + } + + @Override + @SuppressWarnings("unchecked") + public List assist(String entityType, String context, String instruction, List fields) { + List> fieldPayload = new ArrayList<>(); + Set allowed = new HashSet<>(); + if (fields != null) { + for (FieldSpec f : fields) { + if (f == null || f.key() == null) continue; + allowed.add(f.key()); + Map fm = new LinkedHashMap<>(); + fm.put("key", f.key()); + fm.put("label", f.label() == null ? f.key() : f.label()); + fieldPayload.add(fm); + } + } + + Map req = new LinkedHashMap<>(); + req.put("entity_type", entityType == null ? "" : entityType); + req.put("context", context == null ? "" : context); + req.put("instruction", instruction == null ? "" : instruction); + req.put("fields", fieldPayload); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(req, headers); + + Map resp; + try { + resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class); + } catch (ResourceAccessException e) { + throw new NarrativeAssistException("Le Brain est injoignable (timeout ou arrêté).", e); + } catch (RestClientResponseException e) { + throw new NarrativeAssistException( + "Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e); + } catch (Exception e) { + throw new NarrativeAssistException("Erreur inattendue lors de l'appel au Brain.", e); + } + if (resp == null) { + throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide."); + } + + List out = new ArrayList<>(); + Object rawFields = resp.get("fields"); + if (rawFields instanceof Map m) { + for (Map.Entry e : m.entrySet()) { + String key = e.getKey() == null ? null : e.getKey().toString(); + if (key == null || !allowed.contains(key)) continue; // whitelist stricte + String value = e.getValue() == null ? null : e.getValue().toString(); + if (value == null || value.isBlank()) continue; // pas de remplissage vide + out.add(new ProposedField(key, value.trim())); + } + } + return out; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainSceneDraftClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainSceneDraftClient.java new file mode 100644 index 0000000..8b4d3ec --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainSceneDraftClient.java @@ -0,0 +1,91 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.campaigncontext.SceneDraft; +import com.loremind.domain.campaigncontext.ports.NarrativeAssistException; +import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Adapter de sortie : ébauche des scènes pour un chapitre via le Brain (POST one-shot). + * Calqué sur les autres clients Brain. Ne retient que les ébauches avec un titre non vide. + */ +@Component +public class BrainSceneDraftClient implements SceneDraftAssistant { + + private static final String GENERATE_PATH = "/generate/scene-drafts"; + + private final RestTemplate restTemplate; + private final String baseUrl; + + public BrainSceneDraftClient( + RestTemplate restTemplate, + @Value("${brain.base-url}") String baseUrl) { + this.restTemplate = restTemplate; + this.baseUrl = baseUrl; + } + + @Override + @SuppressWarnings("unchecked") + public List draftScenes(String context, String instruction, int count) { + Map req = new LinkedHashMap<>(); + req.put("context", context == null ? "" : context); + req.put("instruction", instruction == null ? "" : instruction); + req.put("count", count); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(req, headers); + + Map resp; + try { + resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class); + } catch (ResourceAccessException e) { + throw new NarrativeAssistException("Le Brain est injoignable (timeout ou arrêté).", e); + } catch (RestClientResponseException e) { + throw new NarrativeAssistException( + "Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e); + } catch (Exception e) { + throw new NarrativeAssistException("Erreur inattendue lors de l'appel au Brain.", e); + } + if (resp == null) { + throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide."); + } + + List out = new ArrayList<>(); + Object rawScenes = resp.get("scenes"); + if (rawScenes instanceof List list) { + for (Object item : list) { + if (!(item instanceof Map m)) continue; + String name = asString(m.get("name")); + if (name == null || name.isBlank()) continue; // un titre est obligatoire + out.add(new SceneDraft( + name.trim(), + trimOrNull(asString(m.get("description"))), + trimOrNull(asString(m.get("playerNarration"))))); + } + } + return out; + } + + private static String asString(Object o) { + return o != null ? o.toString() : null; + } + + private static String trimOrNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainSessionRecapClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainSessionRecapClient.java new file mode 100644 index 0000000..dd2c8f6 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainSessionRecapClient.java @@ -0,0 +1,64 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.playcontext.ports.SessionRecapAssistant; +import com.loremind.domain.playcontext.ports.SessionRecapException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Adapter de sortie : récap « précédemment… » d'une séance via le Brain (POST one-shot). + * Calqué sur les autres clients Brain. + */ +@Component +public class BrainSessionRecapClient implements SessionRecapAssistant { + + private static final String GENERATE_PATH = "/generate/session-recap"; + + private final RestTemplate restTemplate; + private final String baseUrl; + + public BrainSessionRecapClient( + RestTemplate restTemplate, + @Value("${brain.base-url}") String baseUrl) { + this.restTemplate = restTemplate; + this.baseUrl = baseUrl; + } + + @Override + @SuppressWarnings("unchecked") + public String generateRecap(String transcript, String context) { + Map req = new LinkedHashMap<>(); + req.put("transcript", transcript == null ? "" : transcript); + req.put("context", context == null ? "" : context); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(req, headers); + + Map resp; + try { + resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class); + } catch (ResourceAccessException e) { + throw new SessionRecapException("Le Brain est injoignable (timeout ou arrêté).", e); + } catch (RestClientResponseException e) { + throw new SessionRecapException( + "Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e); + } catch (Exception e) { + throw new SessionRecapException("Erreur inattendue lors de l'appel au Brain.", e); + } + Object recap = resp != null ? resp.get("recap") : null; + if (recap == null || recap.toString().isBlank()) { + throw new SessionRecapException("Le Brain a renvoyé un récap vide."); + } + return recap.toString().trim(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/QuestNodeListJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/QuestNodeListJsonConverter.java new file mode 100644 index 0000000..c32da4c --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/QuestNodeListJsonConverter.java @@ -0,0 +1,67 @@ +package com.loremind.infrastructure.persistence.converter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Convertit une {@code List} (VO du domaine) en JSON stocké en + * colonne TEXT, et inversement. + *

+ * Même approche que {@code PrerequisiteListJsonConverter} : conversion manuelle + * map ↔ record pour garder le domaine pur (zéro annotation Jackson) et un format + * on-disk stable, indépendant des noms de classes Java. + *

+ * Format JSON : + * [ {"nodeType": "CHAPTER", "nodeId": "42", "order": 0}, ... ] + */ +@Converter +public class QuestNodeListJsonConverter implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) return "[]"; + try { + List> raw = new ArrayList<>(attribute.size()); + for (QuestNodeRef n : attribute) { + Map m = new HashMap<>(); + m.put("nodeType", n.nodeType().name()); + m.put("nodeId", n.nodeId()); + m.put("order", n.order()); + raw.add(m); + } + return MAPPER.writeValueAsString(raw); + } catch (Exception e) { + throw new IllegalStateException("Erreur sérialisation List → JSON", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) return new ArrayList<>(); + try { + List> raw = MAPPER.readValue(dbData, new TypeReference<>() {}); + List result = new ArrayList<>(raw.size()); + for (Map m : raw) { + NodeType type = NodeType.valueOf(String.valueOf(m.get("nodeType"))); + String nodeId = String.valueOf(m.get("nodeId")); + Object o = m.get("order"); + int order = (o instanceof Number num) ? num.intValue() : 0; + result.add(new QuestNodeRef(type, nodeId, order)); + } + return result; + } catch (Exception e) { + throw new IllegalStateException("Erreur désérialisation JSON → List", e); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/SceneBattlemapListJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/SceneBattlemapListJsonConverter.java new file mode 100644 index 0000000..830fd0b --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/SceneBattlemapListJsonConverter.java @@ -0,0 +1,45 @@ +package com.loremind.infrastructure.persistence.converter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.SceneBattlemap; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.Collections; +import java.util.List; + +/** + * Convertit une List du domaine en chaîne JSON stockée en base, + * et inversement. Même pattern que SceneBranchListJsonConverter. + */ +@Converter +public class SceneBattlemapListJsonConverter implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) { + return "[]"; + } + try { + return MAPPER.writeValueAsString(attribute); + } catch (Exception e) { + throw new IllegalStateException("Erreur sérialisation List → JSON", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return Collections.emptyList(); + } + try { + return MAPPER.readValue(dbData, new TypeReference<>() { + }); + } catch (Exception e) { + throw new IllegalStateException("Erreur désérialisation JSON → List", e); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java index bf778bd..c9278d0 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java @@ -1,7 +1,5 @@ package com.loremind.infrastructure.persistence.entity; -import com.loremind.domain.campaigncontext.Prerequisite; -import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -39,16 +37,6 @@ public class ChapterJpaEntity { @Column(name = "\"order\"", nullable = false) private int order; - /** - * Conditions de déblocage (combinées en ET). Sérialisées en JSON dans une colonne TEXT - * via {@link PrerequisiteListJsonConverter} (sealed type côté domaine). - * Donnée de SCÉNARIO — l'état réel est sur Playthrough.QuestProgression. - */ - @Column(name = "prerequisites", columnDefinition = "TEXT") - @Convert(converter = PrerequisiteListJsonConverter.class) - @Builder.Default - private List prerequisites = new ArrayList<>(); - @Column private String icon; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ClockJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ClockJpaEntity.java new file mode 100644 index 0000000..4744174 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ClockJpaEntity.java @@ -0,0 +1,76 @@ +package com.loremind.infrastructure.persistence.entity; + +import com.loremind.domain.playcontext.ClockTrigger; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Entité JPA pour la persistance des Horloges (Clocks) en PostgreSQL. + * Adaptateur d'infrastructure — n'est PAS dans le domaine. + */ +@Entity +@Table(name = "clocks", indexes = @Index(name = "ix_clock_playthrough", columnList = "playthrough_id")) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClockJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "playthrough_id", nullable = false) + private Long playthroughId; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(nullable = false) + private int segments; + + @Column(nullable = false) + private int filled; + + @Column(name = "\"order\"", nullable = false) + private int order; + + /** Déclencheur d'avancement auto (co-MJ). Défaut NONE ; colonne dotée d'un default SQL (V16). */ + @Enumerated(EnumType.STRING) + @Column(name = "trigger_type", length = 32) + @Builder.Default + private ClockTrigger triggerType = ClockTrigger.NONE; + + @Column(name = "trigger_ref") + private String triggerRef; + + /** Front (menace) auquel l'horloge appartient (null = libre). Weak reference, pas de FK. */ + @Column(name = "front_id") + private Long frontId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/FrontJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/FrontJpaEntity.java new file mode 100644 index 0000000..f72d847 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/FrontJpaEntity.java @@ -0,0 +1,56 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Entité JPA pour la persistance des Fronts en PostgreSQL. + * Adaptateur d'infrastructure — n'est PAS dans le domaine. + */ +@Entity +@Table(name = "fronts", indexes = @Index(name = "ix_front_playthrough", columnList = "playthrough_id")) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FrontJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "playthrough_id", nullable = false) + private Long playthroughId; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "\"order\"", nullable = false) + private int order; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestJpaEntity.java new file mode 100644 index 0000000..1ce5ba6 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestJpaEntity.java @@ -0,0 +1,101 @@ +package com.loremind.infrastructure.persistence.entity; + +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; +import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter; +import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Entité JPA pour la persistance des Quests (table {@code quests}). + * + *

Calquée sur {@code ChapterJpaEntity} : rattachement campagne ({@code campaign_id}) + * au lieu d'arc, + colonne {@code nodes} (liens vers chapitres/scènes) sérialisée + * en JSON. Le converter {@link PrerequisiteListJsonConverter} est RÉUTILISÉ tel + * quel pour les prérequis (format on-disk identique à celui des chapitres).

+ */ +@Entity +@Table(name = "quests") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class QuestJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "campaign_id", nullable = false) + private Long campaignId; + + /** Arc de rattachement (nullable, weak ref — pas de FK, agrégat Quest indépendant). */ + @Column(name = "arc_id") + private Long arcId; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column + private String icon; + + @Column(name = "\"order\"", nullable = false) + private int order; + + @Column(name = "prerequisites", columnDefinition = "TEXT") + @Convert(converter = PrerequisiteListJsonConverter.class) + @Builder.Default + private List prerequisites = new ArrayList<>(); + + @Column(name = "nodes", columnDefinition = "TEXT") + @Convert(converter = QuestNodeListJsonConverter.class) + @Builder.Default + private List nodes = new ArrayList<>(); + + @Column(name = "gm_notes", columnDefinition = "TEXT") + private String gmNotes; + + @Column(name = "player_objectives", columnDefinition = "TEXT") + private String playerObjectives; + + @Column(name = "narrative_stakes", columnDefinition = "TEXT") + private String narrativeStakes; + + @Column(name = "related_page_ids", columnDefinition = "TEXT") + @Convert(converter = StringListJsonConverter.class) + @Builder.Default + private List relatedPageIds = new ArrayList<>(); + + @Column(name = "illustration_image_ids", columnDefinition = "TEXT") + @Convert(converter = StringListJsonConverter.class) + @Builder.Default + private List illustrationImageIds = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestProgressionJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestProgressionJpaEntity.java index 4b32671..da1c729 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestProgressionJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestProgressionJpaEntity.java @@ -8,17 +8,17 @@ import lombok.Data; import lombok.NoArgsConstructor; /** - * Entité JPA pour la progression d'une quête (Chapter) au sein d'un Playthrough. + * Entité JPA pour la progression d'une quête (Quest) au sein d'un Playthrough. * - *

Contrainte d'unicité sur (playthrough_id, chapter_id) : une seule ligne par + *

Contrainte d'unicité sur (playthrough_id, quest_id) : une seule ligne par * couple quête × partie. L'absence de ligne = NOT_STARTED.

*/ @Entity @Table( name = "quest_progression", uniqueConstraints = @UniqueConstraint( - name = "uk_quest_progression_unique", - columnNames = {"playthrough_id", "chapter_id"} + name = "uk_quest_progression_quest", + columnNames = {"playthrough_id", "quest_id"} ), indexes = @Index(name = "ix_quest_progression_playthrough", columnList = "playthrough_id") ) @@ -35,8 +35,8 @@ public class QuestProgressionJpaEntity { @Column(name = "playthrough_id", nullable = false) private Long playthroughId; - @Column(name = "chapter_id", nullable = false) - private Long chapterId; + @Column(name = "quest_id", nullable = false) + private Long questId; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 16) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java index bbedcd8..47e6c61 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java @@ -1,8 +1,11 @@ package com.loremind.infrastructure.persistence.entity; import com.loremind.domain.campaigncontext.Room; +import com.loremind.domain.campaigncontext.SceneBattlemap; import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.domain.campaigncontext.SceneType; import com.loremind.infrastructure.persistence.converter.RoomListJsonConverter; +import com.loremind.infrastructure.persistence.converter.SceneBattlemapListJsonConverter; import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import jakarta.persistence.*; @@ -44,6 +47,12 @@ public class SceneJpaEntity { @Column private String icon; + /** Type narratif du nœud (Niveau 2). Défaut GENERIC ; colonne dotée d'un default SQL (V13). */ + @Enumerated(EnumType.STRING) + @Column(name = "scene_type", length = 32) + @Builder.Default + private SceneType type = SceneType.GENERIC; + // Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update) // Contexte et ambiance @@ -91,13 +100,21 @@ public class SceneJpaEntity { @Builder.Default private List illustrationImageIds = new ArrayList<>(); - /** Battlemap Foundry : fichier media (image/video). Null = pas de carte. */ - @Column(name = "battlemap_media_file_id") - private String battlemapMediaFileId; + /** + * Battlemaps Foundry : liste de paires { media + sidecar } étiquetées + * (variantes Jour/Nuit, étages…). JSON en TEXT via converter (V22). + */ + @Column(name = "battlemaps", columnDefinition = "TEXT") + @Convert(converter = SceneBattlemapListJsonConverter.class) + @Builder.Default + private List battlemaps = new ArrayList<>(); - /** Battlemap Foundry : fichier sidecar Universal VTT (json/dd2vtt). Null si absent. */ - @Column(name = "battlemap_data_file_id") - private String battlemapDataFileId; + /** Position du nœud dans la vue graphe du chapitre (Niveau 2). Null = layout auto. */ + @Column(name = "graph_x") + private Double graphX; + + @Column(name = "graph_y") + private Double graphY; // Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes. // Persisté en TEXT JSON via converter (pattern homogène avec les autres listes). diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java index 350fcdc..e851a10 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java @@ -49,6 +49,10 @@ public class SessionJpaEntity { @Column(name = "ended_at") private LocalDateTime endedAt; + /** Scène courante épinglée (weak ref nullable, mode cockpit). */ + @Column(name = "current_scene_id") + private Long currentSceneId; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ClockJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ClockJpaRepository.java new file mode 100644 index 0000000..993522f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ClockJpaRepository.java @@ -0,0 +1,16 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.ClockJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Repository Spring Data JPA pour ClockJpaEntity. + */ +@Repository +public interface ClockJpaRepository extends JpaRepository { + + List findByPlaythroughIdOrderByOrderAsc(Long playthroughId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/FrontJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/FrontJpaRepository.java new file mode 100644 index 0000000..70c9c8f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/FrontJpaRepository.java @@ -0,0 +1,16 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.FrontJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Repository Spring Data JPA pour FrontJpaEntity. + */ +@Repository +public interface FrontJpaRepository extends JpaRepository { + + List findByPlaythroughIdOrderByOrderAsc(Long playthroughId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestJpaRepository.java new file mode 100644 index 0000000..4116635 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestJpaRepository.java @@ -0,0 +1,18 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.QuestJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Repository Spring Data JPA pour QuestJpaEntity. + */ +@Repository +public interface QuestJpaRepository extends JpaRepository { + + List findByCampaignId(Long campaignId); + + List findByArcId(Long arcId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestProgressionJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestProgressionJpaRepository.java index bdd64fb..8f1a058 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestProgressionJpaRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestProgressionJpaRepository.java @@ -16,10 +16,15 @@ public interface QuestProgressionJpaRepository extends JpaRepository findByPlaythroughId(Long playthroughId); - Optional findByPlaythroughIdAndChapterId(Long playthroughId, Long chapterId); + Optional findByPlaythroughIdAndQuestId(Long playthroughId, Long questId); @Modifying @Transactional @Query("DELETE FROM QuestProgressionJpaEntity q WHERE q.playthroughId = :playthroughId") void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId); + + @Modifying + @Transactional + @Query("DELETE FROM QuestProgressionJpaEntity q WHERE q.questId = :questId") + void deleteByQuestId(@Param("questId") Long questId); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java b/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java index 88602ee..220c91c 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java @@ -117,13 +117,15 @@ public class PlaythroughMigrationRunner { ); } - private int migrateProgressions(JdbcTemplate jdbc) { + int migrateProgressions(JdbcTemplate jdbc) { if (!columnExists(jdbc, "chapters", "progression_status")) return 0; if (!tableExists(jdbc, "quest_progression")) return 0; - // On copie les chapitres dont la progression était != NOT_STARTED, vers la Partie principale - // de la campagne du chapitre. Déduplique via NOT EXISTS sur (playthrough_id, chapter_id). + // On copie les chapitres dont la progression était != NOT_STARTED, vers la Partie + // principale de la campagne du chapitre. Niveau 1 : la colonne cible est quest_id + // (renommée par V11). L'id du chapitre == id de la quête (continuité d'id, décision D4). + // Déduplique via NOT EXISTS sur (playthrough_id, quest_id). return jdbc.update( - "INSERT INTO quest_progression (playthrough_id, chapter_id, status) " + + "INSERT INTO quest_progression (playthrough_id, quest_id, status) " + "SELECT p.id, ch.id, ch.progression_status " + "FROM chapters ch " + "JOIN arcs a ON a.id = ch.arc_id " + @@ -132,7 +134,7 @@ public class PlaythroughMigrationRunner { " AND ch.progression_status <> 'NOT_STARTED' " + " AND NOT EXISTS (" + " SELECT 1 FROM quest_progression qp " + - " WHERE qp.playthrough_id = p.id AND qp.chapter_id = ch.id" + + " WHERE qp.playthrough_id = p.id AND qp.quest_id = ch.id" + " )" ); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java index 7572e9f..1c75306 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java @@ -71,9 +71,6 @@ public class PostgresChapterRepository implements ChapterRepository { .description(jpaEntity.getDescription()) .arcId(jpaEntity.getArcId().toString()) .order(jpaEntity.getOrder()) - .prerequisites(jpaEntity.getPrerequisites() != null - ? new ArrayList<>(jpaEntity.getPrerequisites()) - : new ArrayList<>()) .icon(jpaEntity.getIcon()) .gmNotes(jpaEntity.getGmNotes()) .playerObjectives(jpaEntity.getPlayerObjectives()) @@ -97,9 +94,6 @@ public class PostgresChapterRepository implements ChapterRepository { .description(chapter.getDescription()) .arcId(Long.parseLong(chapter.getArcId())) .order(chapter.getOrder()) - .prerequisites(chapter.getPrerequisites() != null - ? new ArrayList<>(chapter.getPrerequisites()) - : new ArrayList<>()) .icon(chapter.getIcon()) .gmNotes(chapter.getGmNotes()) .playerObjectives(chapter.getPlayerObjectives()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresClockRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresClockRepository.java new file mode 100644 index 0000000..dbbbb9c --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresClockRepository.java @@ -0,0 +1,87 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.playcontext.Clock; +import com.loremind.domain.playcontext.ClockTrigger; +import com.loremind.domain.playcontext.ports.ClockRepository; +import com.loremind.infrastructure.persistence.entity.ClockJpaEntity; +import com.loremind.infrastructure.persistence.jpa.ClockJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Adaptateur d'infrastructure : implémente le port {@link ClockRepository} via JPA/Postgres. + */ +@Repository +public class PostgresClockRepository implements ClockRepository { + + private final ClockJpaRepository jpaRepository; + + public PostgresClockRepository(ClockJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Clock save(Clock clock) { + return toDomain(jpaRepository.save(toJpaEntity(clock))); + } + + @Override + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain); + } + + @Override + public List findByPlaythroughId(String playthroughId) { + return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + private Clock toDomain(ClockJpaEntity e) { + return Clock.builder() + .id(e.getId().toString()) + .playthroughId(e.getPlaythroughId() != null ? e.getPlaythroughId().toString() : null) + .name(e.getName()) + .description(e.getDescription()) + .segments(e.getSegments()) + .filled(e.getFilled()) + .order(e.getOrder()) + .triggerType(e.getTriggerType() != null ? e.getTriggerType() : ClockTrigger.NONE) + .triggerRef(e.getTriggerRef()) + .frontId(e.getFrontId() != null ? e.getFrontId().toString() : null) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } + + private ClockJpaEntity toJpaEntity(Clock c) { + Long id = c.getId() != null ? Long.parseLong(c.getId()) : null; + return ClockJpaEntity.builder() + .id(id) + .playthroughId(c.getPlaythroughId() != null ? Long.parseLong(c.getPlaythroughId()) : null) + .name(c.getName()) + .description(c.getDescription()) + .segments(c.getSegments()) + .filled(c.getFilled()) + .order(c.getOrder()) + .triggerType(c.getTriggerType() != null ? c.getTriggerType() : ClockTrigger.NONE) + .triggerRef(c.getTriggerRef()) + .frontId(c.getFrontId() != null ? Long.parseLong(c.getFrontId()) : null) + .createdAt(c.getCreatedAt()) + .updatedAt(c.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresFrontRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresFrontRepository.java new file mode 100644 index 0000000..f1168f1 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresFrontRepository.java @@ -0,0 +1,76 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.playcontext.Front; +import com.loremind.domain.playcontext.ports.FrontRepository; +import com.loremind.infrastructure.persistence.entity.FrontJpaEntity; +import com.loremind.infrastructure.persistence.jpa.FrontJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Adaptateur d'infrastructure : implémente le port {@link FrontRepository} via JPA/Postgres. + */ +@Repository +public class PostgresFrontRepository implements FrontRepository { + + private final FrontJpaRepository jpaRepository; + + public PostgresFrontRepository(FrontJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Front save(Front front) { + return toDomain(jpaRepository.save(toJpaEntity(front))); + } + + @Override + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain); + } + + @Override + public List findByPlaythroughId(String playthroughId) { + return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + private Front toDomain(FrontJpaEntity e) { + return Front.builder() + .id(e.getId().toString()) + .playthroughId(e.getPlaythroughId() != null ? e.getPlaythroughId().toString() : null) + .name(e.getName()) + .description(e.getDescription()) + .order(e.getOrder()) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } + + private FrontJpaEntity toJpaEntity(Front f) { + Long id = f.getId() != null ? Long.parseLong(f.getId()) : null; + return FrontJpaEntity.builder() + .id(id) + .playthroughId(f.getPlaythroughId() != null ? Long.parseLong(f.getPlaythroughId()) : null) + .name(f.getName()) + .description(f.getDescription()) + .order(f.getOrder()) + .createdAt(f.getCreatedAt()) + .updatedAt(f.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java index f80c792..680afab 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java @@ -29,34 +29,34 @@ public class PostgresQuestProgressionRepository implements QuestProgressionRepos } @Override - public Set findCompletedChapterIdsByPlaythroughId(String playthroughId) { + public Set findCompletedQuestIdsByPlaythroughId(String playthroughId) { Set out = new HashSet<>(); for (QuestProgressionJpaEntity e : jpa.findByPlaythroughId(Long.parseLong(playthroughId))) { if (e.getStatus() == ProgressionStatus.COMPLETED) { - out.add(e.getChapterId().toString()); + out.add(e.getQuestId().toString()); } } return out; } @Override - public void setStatus(String playthroughId, String chapterId, ProgressionStatus status) { + public void setStatus(String playthroughId, String questId, ProgressionStatus status) { Long pid = Long.parseLong(playthroughId); - Long cid = Long.parseLong(chapterId); + Long qid = Long.parseLong(questId); // Sémantique : NOT_STARTED = absence de ligne. if (status == null || status == ProgressionStatus.NOT_STARTED) { - jpa.findByPlaythroughIdAndChapterId(pid, cid) + jpa.findByPlaythroughIdAndQuestId(pid, qid) .ifPresent(e -> jpa.deleteById(e.getId())); return; } - jpa.findByPlaythroughIdAndChapterId(pid, cid).ifPresentOrElse( + jpa.findByPlaythroughIdAndQuestId(pid, qid).ifPresentOrElse( existing -> { existing.setStatus(status); jpa.save(existing); }, () -> jpa.save(QuestProgressionJpaEntity.builder() .playthroughId(pid) - .chapterId(cid) + .questId(qid) .status(status) .build()) ); @@ -67,11 +67,16 @@ public class PostgresQuestProgressionRepository implements QuestProgressionRepos jpa.deleteByPlaythroughId(Long.parseLong(playthroughId)); } + @Override + public void deleteByQuestId(String questId) { + jpa.deleteByQuestId(Long.parseLong(questId)); + } + private QuestProgression toDomain(QuestProgressionJpaEntity e) { return QuestProgression.builder() .id(e.getId().toString()) .playthroughId(e.getPlaythroughId().toString()) - .chapterId(e.getChapterId().toString()) + .questId(e.getQuestId().toString()) .status(e.getStatus()) .build(); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestRepository.java new file mode 100644 index 0000000..f2bded8 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestRepository.java @@ -0,0 +1,129 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.infrastructure.persistence.entity.QuestJpaEntity; +import com.loremind.infrastructure.persistence.jpa.QuestJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Adaptateur d'infrastructure qui implémente le Port QuestRepository. + * Calqué sur {@code PostgresChapterRepository}. + */ +@Repository +public class PostgresQuestRepository implements QuestRepository { + + private final QuestJpaRepository jpaRepository; + + public PostgresQuestRepository(QuestJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Quest save(Quest quest) { + QuestJpaEntity saved = jpaRepository.save(toJpaEntity(quest)); + return toDomainEntity(saved); + } + + @Override + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)) + .map(this::toDomainEntity); + } + + @Override + public List findByCampaignId(String campaignId) { + return jpaRepository.findByCampaignId(Long.parseLong(campaignId)).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + + @Override + public List findByArcId(String arcId) { + if (arcId == null || arcId.isBlank()) return List.of(); // pas d'arc → aucune quête rattachée + return jpaRepository.findByArcId(Long.parseLong(arcId)).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + + @Override + public List findAll() { + return jpaRepository.findAll().stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + private Quest toDomainEntity(QuestJpaEntity e) { + return Quest.builder() + .id(e.getId().toString()) + .campaignId(e.getCampaignId().toString()) + .arcId(e.getArcId() != null ? e.getArcId().toString() : null) + .name(e.getName()) + .description(e.getDescription()) + .icon(e.getIcon()) + .order(e.getOrder()) + .prerequisites(e.getPrerequisites() != null + ? new ArrayList<>(e.getPrerequisites()) + : new ArrayList<>()) + .nodes(e.getNodes() != null + ? new ArrayList<>(e.getNodes()) + : new ArrayList<>()) + .gmNotes(e.getGmNotes()) + .playerObjectives(e.getPlayerObjectives()) + .narrativeStakes(e.getNarrativeStakes()) + .relatedPageIds(e.getRelatedPageIds() != null + ? new ArrayList<>(e.getRelatedPageIds()) + : new ArrayList<>()) + .illustrationImageIds(e.getIllustrationImageIds() != null + ? new ArrayList<>(e.getIllustrationImageIds()) + : new ArrayList<>()) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } + + private QuestJpaEntity toJpaEntity(Quest q) { + Long id = q.getId() != null ? Long.parseLong(q.getId()) : null; + return QuestJpaEntity.builder() + .id(id) + .campaignId(Long.parseLong(q.getCampaignId())) + .arcId(q.getArcId() != null && !q.getArcId().isBlank() ? Long.parseLong(q.getArcId()) : null) + .name(q.getName()) + .description(q.getDescription()) + .icon(q.getIcon()) + .order(q.getOrder()) + .prerequisites(q.getPrerequisites() != null + ? new ArrayList<>(q.getPrerequisites()) + : new ArrayList<>()) + .nodes(q.getNodes() != null + ? new ArrayList<>(q.getNodes()) + : new ArrayList<>()) + .gmNotes(q.getGmNotes()) + .playerObjectives(q.getPlayerObjectives()) + .narrativeStakes(q.getNarrativeStakes()) + .relatedPageIds(q.getRelatedPageIds() != null + ? new ArrayList<>(q.getRelatedPageIds()) + : new ArrayList<>()) + .illustrationImageIds(q.getIllustrationImageIds() != null + ? new ArrayList<>(q.getIllustrationImageIds()) + : new ArrayList<>()) + .createdAt(q.getCreatedAt()) + .updatedAt(q.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java index 5514585..9e27cb2 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java @@ -1,6 +1,7 @@ package com.loremind.infrastructure.persistence.postgres; import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneType; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.infrastructure.persistence.entity.SceneJpaEntity; import com.loremind.infrastructure.persistence.jpa.SceneJpaRepository; @@ -72,6 +73,7 @@ public class PostgresSceneRepository implements SceneRepository { .chapterId(jpaEntity.getChapterId().toString()) .order(jpaEntity.getOrder()) .icon(jpaEntity.getIcon()) + .type(jpaEntity.getType() != null ? jpaEntity.getType() : SceneType.GENERIC) .location(jpaEntity.getLocation()) .timing(jpaEntity.getTiming()) .atmosphere(jpaEntity.getAtmosphere()) @@ -89,8 +91,11 @@ public class PostgresSceneRepository implements SceneRepository { .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null ? new ArrayList<>(jpaEntity.getIllustrationImageIds()) : new ArrayList<>()) - .battlemapMediaFileId(jpaEntity.getBattlemapMediaFileId()) - .battlemapDataFileId(jpaEntity.getBattlemapDataFileId()) + .battlemaps(jpaEntity.getBattlemaps() != null + ? new ArrayList<>(jpaEntity.getBattlemaps()) + : new ArrayList<>()) + .graphX(jpaEntity.getGraphX()) + .graphY(jpaEntity.getGraphY()) .branches(jpaEntity.getBranches() != null ? new ArrayList<>(jpaEntity.getBranches()) : new ArrayList<>()) @@ -111,6 +116,7 @@ public class PostgresSceneRepository implements SceneRepository { .chapterId(Long.parseLong(scene.getChapterId())) .order(scene.getOrder()) .icon(scene.getIcon()) + .type(scene.getType() != null ? scene.getType() : SceneType.GENERIC) .location(scene.getLocation()) .timing(scene.getTiming()) .atmosphere(scene.getAtmosphere()) @@ -128,8 +134,11 @@ public class PostgresSceneRepository implements SceneRepository { .illustrationImageIds(scene.getIllustrationImageIds() != null ? new ArrayList<>(scene.getIllustrationImageIds()) : new ArrayList<>()) - .battlemapMediaFileId(scene.getBattlemapMediaFileId()) - .battlemapDataFileId(scene.getBattlemapDataFileId()) + .battlemaps(scene.getBattlemaps() != null + ? new ArrayList<>(scene.getBattlemaps()) + : new ArrayList<>()) + .graphX(scene.getGraphX()) + .graphY(scene.getGraphY()) .branches(scene.getBranches() != null ? new ArrayList<>(scene.getBranches()) : new ArrayList<>()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java index f2b7e14..a2bdfeb 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java @@ -76,6 +76,7 @@ public class PostgresSessionRepository implements SessionRepository { .playthroughId(jpa.getPlaythroughId() != null ? jpa.getPlaythroughId().toString() : null) .startedAt(jpa.getStartedAt()) .endedAt(jpa.getEndedAt()) + .currentSceneId(jpa.getCurrentSceneId() != null ? jpa.getCurrentSceneId().toString() : null) .createdAt(jpa.getCreatedAt()) .updatedAt(jpa.getUpdatedAt()) .build(); @@ -89,6 +90,8 @@ public class PostgresSessionRepository implements SessionRepository { .playthroughId(session.getPlaythroughId() != null ? Long.parseLong(session.getPlaythroughId()) : null) .startedAt(session.getStartedAt()) .endedAt(session.getEndedAt()) + .currentSceneId(session.getCurrentSceneId() != null && !session.getCurrentSceneId().isBlank() + ? Long.parseLong(session.getCurrentSceneId()) : null) .createdAt(session.getCreatedAt()) .updatedAt(session.getUpdatedAt()) .build(); diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java index 55126a7..e25c508 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java @@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.Room; import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.images.ports.ImageStorage; import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; +import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter; import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.jpa.*; import com.loremind.infrastructure.transfer.dto.ContentExport; @@ -45,6 +46,7 @@ public class ExportService { // Réutilise le converter JPA pour (dé)sérialiser les prérequis dans le MÊME // format que la base (discriminant "kind"), au lieu de Jackson polymorphe. private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter(); + private static final QuestNodeListJsonConverter NODE_CONVERTER = new QuestNodeListJsonConverter(); private final GameSystemJpaRepository gameSystemRepo; private final LoreJpaRepository loreRepo; @@ -69,6 +71,9 @@ public class ExportService { private final SessionEntryJpaRepository sessionEntryRepo; private final PlaythroughFlagJpaRepository playthroughFlagRepo; private final QuestProgressionJpaRepository questProgressionRepo; + private final QuestJpaRepository questRepo; + private final ClockJpaRepository clockRepo; + private final FrontJpaRepository frontRepo; private final ObjectMapper objectMapper; private final String appVersion; @@ -95,6 +100,9 @@ public class ExportService { SessionEntryJpaRepository sessionEntryRepo, PlaythroughFlagJpaRepository playthroughFlagRepo, QuestProgressionJpaRepository questProgressionRepo, + QuestJpaRepository questRepo, + ClockJpaRepository clockRepo, + FrontJpaRepository frontRepo, ObjectMapper objectMapper, @Nullable BuildProperties buildProperties) { this.gameSystemRepo = gameSystemRepo; @@ -120,6 +128,9 @@ public class ExportService { this.sessionEntryRepo = sessionEntryRepo; this.playthroughFlagRepo = playthroughFlagRepo; this.questProgressionRepo = questProgressionRepo; + this.questRepo = questRepo; + this.clockRepo = clockRepo; + this.frontRepo = frontRepo; this.objectMapper = objectMapper; this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev"; } @@ -163,7 +174,10 @@ public class ExportService { map(sessionRepo.findAll(), this::toSessionDto), map(sessionEntryRepo.findAll(), this::toSessionEntryDto), map(playthroughFlagRepo.findAll(), this::toFlagDto), - map(questProgressionRepo.findAll(), this::toQuestProgressionDto)); + map(questProgressionRepo.findAll(), this::toQuestProgressionDto), + map(questRepo.findAll(), this::toQuestDto), + map(clockRepo.findAll(), this::toClockDto), + map(frontRepo.findAll(), this::toFrontDto)); } /** @@ -209,9 +223,16 @@ public class ExportService { .flatMap(p -> playthroughFlagRepo.findByPlaythroughId(p.getId()).stream()).toList(); List questEntities = ptEntities.stream() .flatMap(p -> questProgressionRepo.findByPlaythroughId(p.getId()).stream()).toList(); + List clockEntities = ptEntities.stream() + .flatMap(p -> clockRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList(); + List frontEntities = ptEntities.stream() + .flatMap(p -> frontRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList(); List characterEntities = ptEntities.stream() .flatMap(p -> characterRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList(); + // Quêtes de la campagne (Niveau 1) — toujours incluses dans la clôture. + List campaignQuests = questRepo.findByCampaignId(cid); + // Images/fichiers : uniquement les binaires RÉFÉRENCÉS par la clôture (si option active). List imageEntities = List.of(); List fileEntities = List.of(); @@ -231,7 +252,10 @@ public class ExportService { .distinct().toList(); Set fileRefs = new LinkedHashSet<>(); - sceneEntities.forEach(s -> { addLong(fileRefs, s.getBattlemapMediaFileId()); addLong(fileRefs, s.getBattlemapDataFileId()); }); + sceneEntities.forEach(s -> { + if (s.getBattlemaps() == null) return; + s.getBattlemaps().forEach(bm -> { addLong(fileRefs, bm.mediaFileId()); addLong(fileRefs, bm.dataFileId()); }); + }); fileEntities = fileRefs.stream() .map(id -> storedFileRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull).toList(); } @@ -268,7 +292,10 @@ public class ExportService { map(sessionEntities, this::toSessionDto), map(entryEntities, this::toSessionEntryDto), map(flagEntities, this::toFlagDto), - map(questEntities, this::toQuestProgressionDto)); + map(questEntities, this::toQuestProgressionDto), + map(campaignQuests, this::toQuestDto), + map(clockEntities, this::toClockDto), + map(frontEntities, this::toFrontDto)); } // ----- Helpers de chargement ----- @@ -409,6 +436,14 @@ public class ExportService { } Set keys = new LinkedHashSet<>(); for (ContentExport.SceneDto s : export.scenes()) { + if (s.battlemaps() != null) { + s.battlemaps().forEach(bm -> { + addFileKey(keys, keyById, bm.mediaFileId()); + addFileKey(keys, keyById, bm.dataFileId()); + }); + } + // Legacy (paire unique) : jamais renseigne sur les nouveaux exports, + // mais ce collecteur sert aussi de reference au format on-disk. addFileKey(keys, keyById, s.battlemapMediaFileId()); addFileKey(keys, keyById, s.battlemapDataFileId()); } @@ -487,8 +522,19 @@ public class ExportService { } private ContentExport.ChapterDto toChapterDto(ChapterJpaEntity e) { + // prerequisitesJson : champ conservé dans le bundle pour la rétro-compat de l'import + // legacy (un vieux backup HUB→quête le lit). Les chapitres n'ont plus de prérequis -> "[]". return new ContentExport.ChapterDto(e.getId(), e.getName(), e.getDescription(), - e.getArcId(), e.getOrder(), PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()), e.getIcon(), + e.getArcId(), e.getOrder(), "[]", e.getIcon(), + e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(), + e.getRelatedPageIds(), e.getIllustrationImageIds()); + } + + private ContentExport.QuestDto toQuestDto(QuestJpaEntity e) { + return new ContentExport.QuestDto( + e.getId(), e.getCampaignId(), e.getArcId(), e.getName(), e.getDescription(), e.getIcon(), e.getOrder(), + PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()), + NODE_CONVERTER.convertToDatabaseColumn(e.getNodes()), e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(), e.getRelatedPageIds(), e.getIllustrationImageIds()); } @@ -499,8 +545,9 @@ public class ExportService { e.getTiming(), e.getAtmosphere(), e.getPlayerNarration(), e.getGmSecretNotes(), e.getChoicesConsequences(), e.getCombatDifficulty(), e.getEnemies(), e.getEnemyIds(), e.getRelatedPageIds(), - e.getIllustrationImageIds(), e.getBattlemapMediaFileId(), - e.getBattlemapDataFileId(), e.getBranches(), e.getRooms()); + e.getIllustrationImageIds(), null, null, // legacy battlemap : plus émis + e.getBattlemaps(), e.getBranches(), e.getRooms(), e.getType(), + e.getGraphX(), e.getGraphY()); } private ContentExport.CharacterDto toCharacterDto(CharacterJpaEntity e) { @@ -561,6 +608,17 @@ public class ExportService { return new ContentExport.PlaythroughDto(e.getId(), e.getCampaignId(), e.getName(), e.getDescription()); } + private ContentExport.ClockDto toClockDto(ClockJpaEntity e) { + return new ContentExport.ClockDto(e.getId(), e.getPlaythroughId(), e.getName(), + e.getDescription(), e.getSegments(), e.getFilled(), e.getOrder(), + e.getTriggerType(), e.getTriggerRef(), e.getFrontId()); + } + + private ContentExport.FrontDto toFrontDto(FrontJpaEntity e) { + return new ContentExport.FrontDto(e.getId(), e.getPlaythroughId(), e.getName(), + e.getDescription(), e.getOrder()); + } + private ContentExport.SessionDto toSessionDto(SessionJpaEntity e) { return new ContentExport.SessionDto(e.getId(), e.getName(), e.getCampaignId(), e.getPlaythroughId(), e.getStartedAt() != null ? e.getStartedAt().toString() : null, @@ -578,7 +636,9 @@ public class ExportService { } private ContentExport.QuestProgressionDto toQuestProgressionDto(QuestProgressionJpaEntity e) { - return new ContentExport.QuestProgressionDto(e.getId(), e.getPlaythroughId(), e.getChapterId(), + // Le champ DTO se nomme encore chapterId (format bundle v1) ; il porte désormais + // le quest id (== chapter id partagé). Le renommage du format est traité en Phase 5. + return new ContentExport.QuestProgressionDto(e.getId(), e.getPlaythroughId(), e.getQuestId(), e.getStatus() != null ? e.getStatus().name() : null); } } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java b/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java index 93194ca..44b5919 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java @@ -1,7 +1,9 @@ package com.loremind.infrastructure.transfer; import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.NodeType; import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.QuestNodeRef; import com.loremind.domain.campaigncontext.SceneBranch; import java.util.ArrayList; @@ -47,12 +49,17 @@ final class IdRemapper { return out; } - static List remapPrerequisites(Map chapterMap, List prereqs) { + /** + * Remappe {@code QuestCompleted.questId} via {@code idMap}. L'appelant fournit la map + * sémantiquement correcte selon le contexte : {@code questMap} pour des prérequis de + * Quête (v2), {@code chapterMap} pour des prérequis de Chapitre legacy (v1). + */ + static List remapPrerequisites(Map idMap, List prereqs) { if (prereqs == null) return null; List out = new ArrayList<>(prereqs.size()); for (Prerequisite p : prereqs) { if (p instanceof Prerequisite.QuestCompleted qc) { - out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId()))); + out.add(new Prerequisite.QuestCompleted(remapStringId(idMap, qc.questId()))); } else { out.add(p); // FlagSet / SessionReached : inchangés } @@ -60,11 +67,23 @@ final class IdRemapper { return out; } + /** Remap des nœuds d'une quête : nodeId via {@code sceneMap} (SCENE) ou {@code chapterMap} (CHAPTER). */ + static List remapQuestNodes(Map chapterMap, Map sceneMap, + List nodes) { + if (nodes == null) return null; + List out = new ArrayList<>(nodes.size()); + for (QuestNodeRef n : nodes) { + Map map = (n.nodeType() == NodeType.SCENE) ? sceneMap : chapterMap; + out.add(new QuestNodeRef(n.nodeType(), remapStringId(map, n.nodeId()), n.order())); + } + return out; + } + static List remapBranches(Map sceneMap, List branches) { if (branches == null) return null; List out = new ArrayList<>(branches.size()); for (SceneBranch b : branches) { - out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition())); + out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition(), b.kind())); } return out; } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java index 774f675..8b6de0b 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java @@ -1,9 +1,16 @@ package com.loremind.infrastructure.transfer; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.domain.campaigncontext.SceneBattlemap; +import com.loremind.domain.campaigncontext.SceneType; +import com.loremind.domain.playcontext.ClockTrigger; import com.loremind.domain.playcontext.EntryType; import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; +import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter; import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.jpa.*; import com.loremind.infrastructure.transfer.dto.ContentExport; @@ -16,9 +23,11 @@ import java.io.InputStream; import java.io.UncheckedIOException; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -48,6 +57,7 @@ public class ImportService { // (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite // est scellé, non sérialisable en polymorphe par l'ObjectMapper standard). private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter(); + private static final QuestNodeListJsonConverter NODE_CONVERTER = new QuestNodeListJsonConverter(); private final GameSystemJpaRepository gameSystemRepo; private final LoreJpaRepository loreRepo; @@ -68,6 +78,9 @@ public class ImportService { private final SessionEntryJpaRepository sessionEntryRepo; private final PlaythroughFlagJpaRepository playthroughFlagRepo; private final QuestProgressionJpaRepository questProgressionRepo; + private final QuestJpaRepository questRepo; + private final ClockJpaRepository clockRepo; + private final FrontJpaRepository frontRepo; private final ImageImporter imageImporter; private final StoredFileImporter storedFileImporter; private final ObjectMapper objectMapper; @@ -91,6 +104,9 @@ public class ImportService { SessionEntryJpaRepository sessionEntryRepo, PlaythroughFlagJpaRepository playthroughFlagRepo, QuestProgressionJpaRepository questProgressionRepo, + QuestJpaRepository questRepo, + ClockJpaRepository clockRepo, + FrontJpaRepository frontRepo, ImageImporter imageImporter, StoredFileImporter storedFileImporter, ObjectMapper objectMapper) { @@ -113,6 +129,9 @@ public class ImportService { this.sessionEntryRepo = sessionEntryRepo; this.playthroughFlagRepo = playthroughFlagRepo; this.questProgressionRepo = questProgressionRepo; + this.questRepo = questRepo; + this.clockRepo = clockRepo; + this.frontRepo = frontRepo; this.imageImporter = imageImporter; this.storedFileImporter = storedFileImporter; this.objectMapper = objectMapper; @@ -143,8 +162,11 @@ public class ImportService { Map enemyMap = new HashMap<>(); Map characterMap = new HashMap<>(); Map sceneMap = new HashMap<>(); + Map questMap = new HashMap<>(); Map playthroughMap = new HashMap<>(); Map sessionMap = new HashMap<>(); + Map clockMap = new HashMap<>(); + Map frontMap = new HashMap<>(); // -- GameSystem for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) { @@ -281,6 +303,33 @@ public class ImportService { } result.count("playthroughFlags", flagCount); + // -- Front (menaces regroupant des horloges) : playthroughId remappé. + for (ContentExport.FrontDto d : nullSafe(export.fronts())) { + FrontJpaEntity e = new FrontJpaEntity(); + e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); + e.setName(d.name()); + e.setDescription(d.description()); + e.setOrder(d.order()); + frontMap.put(d.id(), frontRepo.save(e).getId()); + } + result.count("fronts", frontMap.size()); + + // -- Clock (horloges de Partie) : playthroughId + frontId remappés ; triggerRef quête -> 2e passe. + for (ContentExport.ClockDto d : nullSafe(export.clocks())) { + ClockJpaEntity e = new ClockJpaEntity(); + e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); + e.setName(d.name()); + e.setDescription(d.description()); + e.setSegments(d.segments()); + e.setFilled(d.filled()); + e.setOrder(d.order()); + e.setTriggerType(d.triggerType() != null ? d.triggerType() : ClockTrigger.NONE); + e.setTriggerRef(d.triggerRef()); + e.setFrontId(IdRemapper.remapId(frontMap, d.frontId())); + clockMap.put(d.id(), clockRepo.save(e).getId()); + } + result.count("clocks", clockMap.size()); + // -- Arc (relatedPageIds remappe en 2e passe) for (ContentExport.ArcDto d : nullSafe(export.arcs())) { ArcJpaEntity e = new ArcJpaEntity(); @@ -358,14 +407,14 @@ public class ImportService { result.count("randomTables", tableCount); result.count("randomTableEntries", entryCount); - // -- Chapter (prerequisites + relatedPageIds remappes en 2e passe) + // -- Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de + // prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy. for (ContentExport.ChapterDto d : nullSafe(export.chapters())) { ChapterJpaEntity e = new ChapterJpaEntity(); e.setName(d.name()); e.setDescription(d.description()); e.setArcId(IdRemapper.remapId(arcMap, d.arcId())); e.setOrder(d.order()); - e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappe plus bas e.setIcon(d.icon()); e.setGmNotes(d.gmNotes()); e.setPlayerObjectives(d.playerObjectives()); @@ -376,13 +425,55 @@ public class ImportService { } result.count("chapters", chapterMap.size()); - // -- QuestProgression : playthroughId + chapterId remappes (chapitres deja inseres ; - // contrainte unique (playthroughId, chapterId) preservee car playthroughId neuf). + boolean bundleHasQuests = export.quests() != null; + + // -- Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ; + // prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt). + for (ContentExport.QuestDto d : nullSafe(export.quests())) { + QuestJpaEntity e = new QuestJpaEntity(); + e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); + e.setArcId(IdRemapper.remapId(arcMap, d.arcId())); // arcMap déjà prêt (arcs importés avant) ; null→null + e.setName(d.name()); + e.setDescription(d.description()); + e.setIcon(d.icon()); + e.setOrder(d.order()); + e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappé 2e passe + e.setNodes(NODE_CONVERTER.convertToEntityAttribute(d.nodesJson())); // remappé 2e passe + e.setGmNotes(d.gmNotes()); + e.setPlayerObjectives(d.playerObjectives()); + e.setNarrativeStakes(d.narrativeStakes()); + e.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe + e.setIllustrationImageIds(d.illustrationImageIds()); + questMap.put(d.id(), questRepo.save(e).getId()); + } + + // -- Quest legacy (bundle SANS champ quests) : on convertit les chapitres qui jouaient le + // rôle de quête (arc HUB OU porteurs de prérequis OU référencés par une progression) en + // vraies Quests, pour ne pas perdre les quêtes d'un vieux backup. Ici questMap est clé + // par ANCIEN CHAPTER id (pas de collision : on compare chapter-id à chapter-id). + if (!bundleHasQuests) { + convertLegacyChaptersToQuests(export, campaignMap, arcMap, questMap); + } + result.count("quests", questMap.size()); + + // -- QuestProgression : playthroughId + (quest id) remappés (entités déjà insérées ; + // contrainte unique (playthroughId, questId) préservée car playthroughId neuf). int questProgCount = 0; for (ContentExport.QuestProgressionDto d : nullSafe(export.questProgressions())) { QuestProgressionJpaEntity e = new QuestProgressionJpaEntity(); e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); - e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId())); + Long oldRef = d.chapterId(); + // v2 : oldRef est un quest id -> questMap. v1 : oldRef est un chapter id ; s'il a été + // converti en quête, questMap (clé chapter id) le résout, sinon fallback chapterMap. + Long newQuestId; + if (bundleHasQuests) { + newQuestId = IdRemapper.remapId(questMap, oldRef); + } else if (oldRef != null && questMap.containsKey(oldRef)) { + newQuestId = questMap.get(oldRef); + } else { + newQuestId = IdRemapper.remapId(chapterMap, oldRef); + } + e.setQuestId(newQuestId); e.setStatus(parseProgressionStatus(d.status())); questProgressionRepo.save(e); questProgCount++; @@ -451,6 +542,7 @@ public class ImportService { e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId())); e.setOrder(d.order()); e.setIcon(d.icon()); + e.setType(d.type() != null ? d.type() : SceneType.GENERIC); e.setLocation(d.location()); e.setTiming(d.timing()); e.setAtmosphere(d.atmosphere()); @@ -462,10 +554,17 @@ public class ImportService { e.setEnemyIds(d.enemyIds()); // remappe plus bas e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas e.setIllustrationImageIds(d.illustrationImageIds()); - // Battlemap : ids StoredFile passes tels quels (meme logique que les refs - // d'images illustration, non remappees). Cf. ImportService doc. - e.setBattlemapMediaFileId(d.battlemapMediaFileId()); - e.setBattlemapDataFileId(d.battlemapDataFileId()); + // Battlemaps : ids StoredFile passes tels quels (meme logique que les refs + // d'images illustration, non remappees). Les exports anterieurs a V22 + // portaient une paire unique -> reconstituee en premiere entree de liste. + List battlemaps = d.battlemaps(); + if ((battlemaps == null || battlemaps.isEmpty()) + && (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) { + battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId())); + } + e.setBattlemaps(battlemaps != null ? battlemaps : List.of()); + e.setGraphX(d.graphX()); + e.setGraphY(d.graphY()); e.setBranches(d.branches()); // remappe plus bas e.setRooms(d.rooms()); // Rooms: UUID, non remappes sceneMap.put(d.id(), sceneRepo.save(e).getId()); @@ -522,11 +621,10 @@ public class ImportService { }); } - // Chapter.relatedPageIds + prerequisites(QuestCompleted -> map Chapter) + // Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1) for (Long newChapterId : chapterMap.values()) { chapterRepo.findById(newChapterId).ifPresent(c -> { c.setRelatedPageIds(IdRemapper.remapStringList(pageMap, c.getRelatedPageIds())); - c.setPrerequisites(IdRemapper.remapPrerequisites(chapterMap, c.getPrerequisites())); chapterRepo.save(c); }); } @@ -549,9 +647,79 @@ public class ImportService { }); } + // Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap), + // relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici. + for (Long newQuestId : questMap.values()) { + questRepo.findById(newQuestId).ifPresent(q -> { + q.setPrerequisites(IdRemapper.remapPrerequisites(questMap, q.getPrerequisites())); + q.setNodes(IdRemapper.remapQuestNodes(chapterMap, sceneMap, q.getNodes())); + q.setRelatedPageIds(IdRemapper.remapStringList(pageMap, q.getRelatedPageIds())); + questRepo.save(q); + }); + } + + // Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée + // (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper). + for (Long newClockId : clockMap.values()) { + clockRepo.findById(newClockId).ifPresent(c -> { + if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) { + c.setTriggerRef(IdRemapper.remapStringId(questMap, c.getTriggerRef())); + clockRepo.save(c); + } + }); + } + return result.build(); } + /** + * Conversion legacy : pour un bundle SANS champ {@code quests}, recrée des Quests à partir + * des chapitres qui jouaient le rôle de quête (arc HUB, OU porteurs de prérequis, OU + * référencés par une {@code quest_progression}). Alimente {@code questMap} (clé = ANCIEN + * chapter id du bundle -> nouvel id de quête). Les prérequis / nœuds / relatedPageIds sont + * remappés en 2e passe, comme pour les quêtes v2. + */ + private void convertLegacyChaptersToQuests(ContentExport export, + Map campaignMap, + Map arcMap, + Map questMap) { + Map arcById = new HashMap<>(); + for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a); + + Set progressedChapterIds = new HashSet<>(); + for (ContentExport.QuestProgressionDto qp : nullSafe(export.questProgressions())) { + if (qp.chapterId() != null) progressedChapterIds.add(qp.chapterId()); + } + + for (ContentExport.ChapterDto d : nullSafe(export.chapters())) { + ContentExport.ArcDto arc = arcById.get(d.arcId()); + boolean isHub = arc != null && "HUB".equals(arc.type()); + List prereqs = PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson()); + boolean hasPrereqs = prereqs != null && !prereqs.isEmpty(); + boolean inProgression = progressedChapterIds.contains(d.id()); + if (!isHub && !hasPrereqs && !inProgression) continue; + + QuestJpaEntity q = new QuestJpaEntity(); + q.setCampaignId(IdRemapper.remapId(campaignMap, arc != null ? arc.campaignId() : null)); + // Quête legacy issue d'un arc HUB → rattachée à cet arc (préserve la structure HUB) ; + // les quêtes issues de prérequis/progression seules restent transverses. + q.setArcId(isHub ? IdRemapper.remapId(arcMap, d.arcId()) : null); + q.setName(d.name()); + q.setDescription(d.description()); + q.setIcon(d.icon()); + q.setOrder(d.order()); + q.setPrerequisites(prereqs); // remappé 2e passe (questMap) + q.setNodes(new ArrayList<>(List.of( + new QuestNodeRef(NodeType.CHAPTER, String.valueOf(d.id()), 0)))); // remappé 2e passe (chapterMap) + q.setGmNotes(d.gmNotes()); + q.setPlayerObjectives(d.playerObjectives()); + q.setNarrativeStakes(d.narrativeStakes()); + q.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe (pageMap) + // illustrationImageIds : les chapitres legacy les conservent de leur côté. + questMap.put(d.id(), questRepo.save(q).getId()); + } + } + // ----- Lecture de l'archive ----- /** Contenu déballé d'un zip d'import : {@code data.json} + binaires images + fichiers. */ diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java index 8babf54..58a2d2d 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java @@ -1,7 +1,9 @@ package com.loremind.infrastructure.transfer.dto; import com.loremind.domain.campaigncontext.Room; +import com.loremind.domain.campaigncontext.SceneBattlemap; import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.domain.campaigncontext.SceneType; import com.loremind.domain.shared.template.TemplateField; import java.util.List; @@ -43,7 +45,13 @@ public record ContentExport( List sessions, List sessionEntries, List playthroughFlags, - List questProgressions + List questProgressions, + // --- Quêtes (Niveau 1). Absent des bundles antérieurs -> null à la relecture (nullSafe). --- + List quests, + // --- Horloges de progression (Clocks, état de Partie). Absent des anciens bundles -> nullSafe. --- + List clocks, + // --- Fronts (menaces regroupant des horloges). Absent des anciens bundles -> nullSafe. --- + List fronts ) { /** @@ -177,10 +185,17 @@ public record ContentExport( List enemyIds, List relatedPageIds, List illustrationImageIds, + // LEGACY (exports antérieurs à V22) : paire unique, lue à l'import pour + // reconstituer une entrée de `battlemaps`. Toujours null sur les nouveaux exports. String battlemapMediaFileId, String battlemapDataFileId, + // Battlemaps multiples (variantes Jour/Nuit, étages…) — remplace la paire ci-dessus. + List battlemaps, List branches, - List rooms + List rooms, + SceneType type, + Double graphX, + Double graphY ) {} public record CharacterDto( @@ -327,11 +342,60 @@ public record ContentExport( boolean value ) {} - /** La progression d'une quête (Chapter) dans une Partie ({@code status} = nom du ProgressionStatus). */ + /** Une horloge de progression d'une Partie (Clock, façon Blades in the Dark). */ + public record ClockDto( + Long id, + Long playthroughId, + String name, + String description, + int segments, + int filled, + int order, + com.loremind.domain.playcontext.ClockTrigger triggerType, + String triggerRef, + Long frontId + ) {} + + /** Un Front (menace regroupant des horloges) d'une Partie. */ + public record FrontDto( + Long id, + Long playthroughId, + String name, + String description, + int order + ) {} + + /** + * La progression d'une quête dans une Partie ({@code status} = nom du ProgressionStatus). + * NB : le champ {@code chapterId} porte désormais le quest id (== chapter id partagé en + * format v1) — nom conservé pour la rétrocompat de lecture des anciens bundles. + */ public record QuestProgressionDto( Long id, Long playthroughId, Long chapterId, String status ) {} + + /** + * Une Quête (Niveau 1) : entité orthogonale rattachée à la campagne. + * {@code prerequisitesJson} / {@code nodesJson} = JSON brut des converters JPA + * (même logique que {@code ChapterDto.prerequisitesJson}). + */ + public record QuestDto( + Long id, + Long campaignId, + Long arcId, + String name, + String description, + String icon, + int order, + String prerequisitesJson, + String nodesJson, + String gmNotes, + String playerObjectives, + String narrativeStakes, + List relatedPageIds, + List illustrationImageIds + ) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java index 18dc22c..f30ab2b 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java @@ -30,6 +30,7 @@ public final class FoundryBundle { public record Data( String formatVersion, Campaign campaign, + Options options, List arcs, List quests, List scenes, @@ -39,6 +40,13 @@ public final class FoundryBundle { List assets ) {} + /** + * Perimetre choisi a l'export (additif, format 1.0) : le module s'en sert pour ne + * creer QUE ce qui a ete demande (ex. cartes+ennemis sans journaux). Un bundle sans + * ce champ (ancien export) = tout inclus — le module doit defaulter a true. + */ + public record Options(boolean maps, boolean journals, boolean tables) {} + public record Campaign(String id, String name, String description, String gameSystemId) {} public record Arc( @@ -59,11 +67,19 @@ public final class FoundryBundle { String playerNarration, String gmSecretNotes, String choicesConsequences, String combatDifficulty, String enemies, List enemyIds, List illustrationAssetIds, Battlemap battlemap, + List battlemaps, List branches, List rooms ) {} public record Battlemap(String mediaAssetId, String dataAssetId) {} + /** + * Battlemap étiquetée (variantes Jour/Nuit, étages…). Le champ {@code battlemap} + * (première carte) est CONSERVÉ pour compatibilité avec les modules Foundry + * existants ; {@code battlemaps} porte la liste complète — additive, format 1.0. + */ + public record LabeledBattlemap(String label, String mediaAssetId, String dataAssetId) {} + public record Branch(String label, String targetSceneId, String condition) {} public record Room( diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java index 821d6b5..f45b317 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java @@ -3,9 +3,9 @@ package com.loremind.infrastructure.transfer.foundry; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.Room; import com.loremind.domain.campaigncontext.RoomBranch; +import com.loremind.domain.campaigncontext.SceneBattlemap; import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.shared.template.FieldType; import com.loremind.domain.shared.template.TemplateField; @@ -90,12 +90,30 @@ public class FoundryExportService { /** Reference d'un binaire a copier dans le zip : chemin cible + cle de stockage. */ public record BinaryRef(String path, String storageKey, boolean image) {} + /** + * Perimetre de l'export, choisi par l'utilisateur dans la modale : + * - maps : Scenes Foundry (battlemaps) + acteurs/tokens des ennemis lies. + * - journals : journaux narratifs (arcs, chapitres/quetes, scenes, PNJ, bestiaire) + * avec leurs illustrations. + * - tables : RollTables. + */ + public record ExportOptions(boolean maps, boolean journals, boolean tables) { + public static ExportOptions all() { return new ExportOptions(true, true, true); } + } + + /** Bundle complet (tout le perimetre) — conserve pour les appels existants. */ + public BuiltBundle buildBundle(String campaignId, String exportedAt) { + return buildBundle(campaignId, exportedAt, ExportOptions.all()); + } + /** * Assemble le bundle d'une campagne (sans toucher aux binaires : peu couteux). + * Le perimetre exclu n'est PAS embarque (ni entites ni binaires) : un export + * « cartes + ennemis » reste leger meme sur une campagne tres illustree. * * @throws NoSuchElementException si la campagne n'existe pas */ - public BuiltBundle buildBundle(String campaignId, String exportedAt) { + public BuiltBundle buildBundle(String campaignId, String exportedAt, ExportOptions opts) { CampaignJpaEntity campaign = campaignRepo.findById(Long.parseLong(campaignId)) .orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId)); @@ -113,44 +131,59 @@ public class FoundryExportService { List arcEntities = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder); for (ArcJpaEntity arc : arcEntities) { + // Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) : + // leurs illustrations ne sont pas embarquees. arcs.add(new FoundryBundle.Arc( str(arc.getId()), arc.getName(), arc.getDescription(), arc.getOrder(), arc.getType() != null ? arc.getType().name() : null, arc.getIcon(), arc.getThemes(), arc.getStakes(), arc.getGmNotes(), arc.getRewards(), arc.getResolution(), - assets.images(arc.getIllustrationImageIds()))); + opts.journals() ? assets.images(arc.getIllustrationImageIds()) : List.of())); for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) { quests.add(new FoundryBundle.Quest( str(ch.getId()), str(arc.getId()), ch.getName(), ch.getDescription(), ch.getOrder(), ch.getIcon(), ch.getPlayerObjectives(), ch.getNarrativeStakes(), ch.getGmNotes(), - prerequisites(ch.getPrerequisites()), assets.images(ch.getIllustrationImageIds()))); + List.of(), opts.journals() ? assets.images(ch.getIllustrationImageIds()) : List.of())); for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) { - scenes.add(toScene(sc, str(ch.getId()), assets)); + scenes.add(toScene(sc, str(ch.getId()), assets, opts)); } } } + // PNJ : purement journal — hors perimetre sans les journaux. List npcs = new ArrayList<>(); - for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { - npcs.add(new FoundryBundle.Persona( - str(n.getId()), n.getName(), n.getFolder(), n.getOrder(), - assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null, - fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets))); + if (opts.journals()) { + for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { + npcs.add(new FoundryBundle.Persona( + str(n.getId()), n.getName(), n.getFolder(), n.getOrder(), + assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null, + fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets))); + } } + // Ennemis : necessaires aux cartes (acteurs/tokens, portrait = image du token) + // ET aux journaux (bestiaire). Les galeries d'images des champs ne servent que + // les journaux -> non embarquees en mode cartes seules. List enemies = new ArrayList<>(); - for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { - enemies.add(new FoundryBundle.Persona( - str(e.getId()), e.getName(), e.getFolder(), e.getOrder(), - assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(), - e.getFoundryRef(), - buildFoundryActor(e, enemyTemplate, foundryActorType), - fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), e.getImageValues(), assets))); + if (opts.maps() || opts.journals()) { + for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { + enemies.add(new FoundryBundle.Persona( + str(e.getId()), e.getName(), e.getFolder(), e.getOrder(), + assets.image(e.getPortraitImageId()), + opts.journals() ? assets.image(e.getHeaderImageId()) : null, + e.getLevel(), + e.getFoundryRef(), + buildFoundryActor(e, enemyTemplate, foundryActorType), + fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), + opts.journals() ? e.getImageValues() : null, assets))); + } } List randomTables = new ArrayList<>(); - for (RandomTableJpaEntity t : randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { + for (RandomTableJpaEntity t : opts.tables() + ? randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId()) + : List.of()) { List entries = new ArrayList<>(); if (t.getEntries() != null) { for (RandomTableEntryJpaEntity en : t.getEntries()) { @@ -166,7 +199,9 @@ public class FoundryExportService { str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId()); FoundryBundle.Data data = new FoundryBundle.Data( - FORMAT_VERSION, campaignNode, arcs, quests, scenes, npcs, enemies, randomTables, assets.assets()); + FORMAT_VERSION, campaignNode, + new FoundryBundle.Options(opts.maps(), opts.journals(), opts.tables()), + arcs, quests, scenes, npcs, enemies, randomTables, assets.assets()); Map counts = new LinkedHashMap<>(); counts.put("arcs", arcs.size()); @@ -218,22 +253,33 @@ public class FoundryExportService { // ----- Mapping Scene ----- - private FoundryBundle.Scene toScene(SceneJpaEntity sc, String questId, AssetRegistry assets) { - FoundryBundle.Battlemap battlemap = battlemap(sc.getBattlemapMediaFileId(), sc.getBattlemapDataFileId(), assets); + private FoundryBundle.Scene toScene(SceneJpaEntity sc, String questId, AssetRegistry assets, ExportOptions opts) { + List battlemaps = opts.maps() + ? battlemaps(sc.getBattlemaps(), assets) + : List.of(); + // Champ legacy `battlemap` (première carte) conservé pour les modules Foundry existants. + FoundryBundle.Battlemap battlemap = battlemaps.isEmpty() ? null + : new FoundryBundle.Battlemap(battlemaps.get(0).mediaAssetId(), battlemaps.get(0).dataAssetId()); return new FoundryBundle.Scene( str(sc.getId()), questId, sc.getName(), sc.getDescription(), sc.getOrder(), sc.getIcon(), sc.getLocation(), sc.getTiming(), sc.getAtmosphere(), sc.getPlayerNarration(), sc.getGmSecretNotes(), sc.getChoicesConsequences(), sc.getCombatDifficulty(), sc.getEnemies(), copy(sc.getEnemyIds()), - assets.images(sc.getIllustrationImageIds()), battlemap, - branches(sc.getBranches()), rooms(sc.getRooms(), assets)); + opts.journals() ? assets.images(sc.getIllustrationImageIds()) : List.of(), + battlemap, battlemaps, + branches(sc.getBranches()), rooms(sc.getRooms(), assets, opts)); } - private FoundryBundle.Battlemap battlemap(String mediaId, String dataId, AssetRegistry assets) { - String media = assets.file(mediaId, "battlemapMedia"); - String data = assets.file(dataId, "battlemapData"); - if (media == null && data == null) return null; - return new FoundryBundle.Battlemap(media, data); + private List battlemaps(List maps, AssetRegistry assets) { + if (maps == null) return List.of(); + List out = new ArrayList<>(); + for (SceneBattlemap bm : maps) { + String media = assets.file(bm.mediaFileId(), "battlemapMedia"); + String data = assets.file(bm.dataFileId(), "battlemapData"); + if (media == null && data == null) continue; + out.add(new FoundryBundle.LabeledBattlemap(bm.label(), media, data)); + } + return out; } private List branches(List branches) { @@ -243,7 +289,7 @@ public class FoundryExportService { return out; } - private List rooms(List rooms, AssetRegistry assets) { + private List rooms(List rooms, AssetRegistry assets, ExportOptions opts) { if (rooms == null) return List.of(); List out = new ArrayList<>(); for (Room r : rooms) { @@ -254,22 +300,7 @@ public class FoundryExportService { out.add(new FoundryBundle.Room( r.getId(), r.getName(), r.getDescription(), r.getEnemies(), copy(r.getEnemyIds()), r.getLoot(), r.getTraps(), r.getGmNotes(), r.getFloor(), r.getOrder(), - assets.images(r.getIllustrationImageIds()), null, rb)); - } - return out; - } - - private List> prerequisites(List prereqs) { - if (prereqs == null || prereqs.isEmpty()) return List.of(); - List> out = new ArrayList<>(); - for (Prerequisite p : prereqs) { - if (p instanceof Prerequisite.QuestCompleted qc) { - out.add(Map.of("type", "questCompleted", "questId", String.valueOf(qc.questId()))); - } else if (p instanceof Prerequisite.SessionReached sr) { - out.add(Map.of("type", "sessionReached", "minSessionNumber", sr.minSessionNumber())); - } else if (p instanceof Prerequisite.FlagSet fs) { - out.add(Map.of("type", "flagSet", "flagName", fs.flagName())); - } + opts.journals() ? assets.images(r.getIllustrationImageIds()) : List.of(), null, rb)); } return out; } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java index 53c29ed..685fa36 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java @@ -1,5 +1,8 @@ package com.loremind.infrastructure.transfer.pdf; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.QuestNodeRef; import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.images.ports.ImageStorage; import com.loremind.domain.shared.template.FieldType; @@ -43,6 +46,7 @@ public class PdfExportService { private final ArcJpaRepository arcRepo; private final ChapterJpaRepository chapterRepo; private final SceneJpaRepository sceneRepo; + private final QuestJpaRepository questRepo; private final NpcJpaRepository npcRepo; private final EnemyJpaRepository enemyRepo; private final GameSystemJpaRepository gameSystemRepo; @@ -56,6 +60,7 @@ public class PdfExportService { public PdfExportService(CampaignJpaRepository campaignRepo, ArcJpaRepository arcRepo, ChapterJpaRepository chapterRepo, SceneJpaRepository sceneRepo, + QuestJpaRepository questRepo, NpcJpaRepository npcRepo, EnemyJpaRepository enemyRepo, GameSystemJpaRepository gameSystemRepo, ImageJpaRepository imageRepo, StoredFileJpaRepository storedFileRepo, @@ -66,6 +71,7 @@ public class PdfExportService { this.arcRepo = arcRepo; this.chapterRepo = chapterRepo; this.sceneRepo = sceneRepo; + this.questRepo = questRepo; this.npcRepo = npcRepo; this.enemyRepo = enemyRepo; this.gameSystemRepo = gameSystemRepo; @@ -116,6 +122,7 @@ public class PdfExportService { cover(body, campaign); narrative(body, bookmarks, campaign); + quests(body, bookmarks, campaign); personas(body, bookmarks, "part-npcs", "Personnages non-joueurs (PNJ)", npcEntries(campaign), npcTemplate, false); personas(body, bookmarks, "part-enemies", "Bestiaire", enemyEntries(campaign), enemyTemplate, true); lore(body, bookmarks, campaign); @@ -144,6 +151,16 @@ public class PdfExportService { List arcs = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder); if (arcs.isEmpty()) return; + // Mode plat (miroir de l'UI Niveau 0) : 1 arc d'UN SEUL chapitre → on masque les + // niveaux Arc/Chapitre et on présente les scènes À PLAT (cohérent avec la sidebar). + if (arcs.size() == 1) { + List only = sortByOrder(chapterRepo.findByArcId(arcs.get(0).getId()), ChapterJpaEntity::getOrder); + if (only.size() == 1) { + narrativeFlat(b, bm, arcs.get(0), only.get(0)); + return; + } + } + b.append("

Structure narrative

"); bm.append(bookmark("Structure narrative", "part-narrative", () -> { StringBuilder sub = new StringBuilder(); @@ -165,7 +182,7 @@ public class PdfExportService { for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) { b.append("
") - .append("Quête") + .append("Chapitre") .append(esc(ch.getName())).append("
"); illustrations(b, ch.getIllustrationImageIds()); block(b, "Description", ch.getDescription()); @@ -182,6 +199,33 @@ public class PdfExportService { } } + /** + * Mode plat : scènes présentées sous « Scènes », sans en-têtes Arc/Chapitre. Le contenu + * narratif éventuel des niveaux masqués reste affiché en intro ({@code block} ignore les + * valeurs vides → en pratique rien n'apparaît pour un arc/chapitre auto-créés et vides). + */ + private void narrativeFlat(StringBuilder b, StringBuilder bm, ArcJpaEntity arc, ChapterJpaEntity ch) { + b.append("

Scènes

"); + bm.append(bookmark("Scènes", "part-narrative", null)); + + illustrations(b, arc.getIllustrationImageIds()); + block(b, "Description", arc.getDescription()); + block(b, "Themes", arc.getThemes()); + block(b, "Enjeux", arc.getStakes()); + block(b, "Recompenses", arc.getRewards()); + block(b, "Resolution", arc.getResolution()); + block(b, "Notes MJ", arc.getGmNotes()); + illustrations(b, ch.getIllustrationImageIds()); + block(b, "Description", ch.getDescription()); + block(b, "Objectifs joueurs", ch.getPlayerObjectives()); + block(b, "Enjeux narratifs", ch.getNarrativeStakes()); + block(b, "Notes MJ", ch.getGmNotes()); + + for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) { + scene(b, sc); + } + } + private void scene(StringBuilder b, SceneJpaEntity sc) { b.append("
") .append("Scène") @@ -194,14 +238,93 @@ public class PdfExportService { block(b, "Choix & consequences", sc.getChoicesConsequences()); block(b, "Difficulte du combat", sc.getCombatDifficulty()); illustrations(b, sc.getIllustrationImageIds()); - // Battlemap (image uniquement ; les videos ne sont pas rendables en PDF). - String battlemap = fileImageUri(sc.getBattlemapMediaFileId()); - if (battlemap != null) { - b.append("
"); + // Battlemaps (images uniquement ; les videos ne sont pas rendables en PDF). + // Le libellé de la variante (Jour/Nuit…) est rendu en légende s'il est renseigné. + if (sc.getBattlemaps() != null) { + for (var bm : sc.getBattlemaps()) { + String battlemap = fileImageUri(bm.mediaFileId()); + if (battlemap == null) continue; + b.append("
"); + if (bm.label() != null && !bm.label().isBlank()) { + b.append("
").append(esc(bm.label())).append("
"); + } + b.append("
"); + } } b.append("
"); } + // ----- Quêtes (Niveau 1 : entités orthogonales à l'arbre, rattachées à la campagne) ----- + + private void quests(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) { + List quests = sortByOrder(questRepo.findByCampaignId(campaign.getId()), QuestJpaEntity::getOrder); + if (quests.isEmpty()) return; + + // Index de noms pour résoudre les références : prérequis -> quête, nœuds -> chapitre/scène. + Map questNames = new HashMap<>(); + for (QuestJpaEntity q : quests) questNames.put(String.valueOf(q.getId()), q.getName()); + Map chapterNames = new HashMap<>(); + Map sceneNames = new HashMap<>(); + for (ArcJpaEntity arc : arcRepo.findByCampaignId(campaign.getId())) { + for (ChapterJpaEntity ch : chapterRepo.findByArcId(arc.getId())) { + chapterNames.put(String.valueOf(ch.getId()), ch.getName()); + for (SceneJpaEntity sc : sceneRepo.findByChapterId(ch.getId())) { + sceneNames.put(String.valueOf(sc.getId()), sc.getName()); + } + } + } + + b.append("

Quêtes

"); + bm.append(bookmark("Quêtes", "part-quests", () -> { + StringBuilder sub = new StringBuilder(); + for (QuestJpaEntity q : quests) sub.append(bookmark(q.getName(), "quest-" + q.getId(), null)); + return sub.toString(); + })); + + for (QuestJpaEntity q : quests) { + b.append("
"); + b.append("
").append(esc(q.getName())).append("
"); + block(b, "Description", q.getDescription()); + block(b, "Conditions de déblocage", renderPrerequisites(q.getPrerequisites(), questNames)); + block(b, "Nœuds liés", renderQuestNodes(q.getNodes(), chapterNames, sceneNames)); + block(b, "Objectifs joueurs", q.getPlayerObjectives()); + block(b, "Enjeux narratifs", q.getNarrativeStakes()); + block(b, "Notes MJ", q.getGmNotes()); + illustrations(b, q.getIllustrationImageIds()); + b.append("
"); + } + } + + /** Prérequis d'une quête en texte lisible (une ligne par condition). */ + private String renderPrerequisites(List prereqs, Map questNames) { + if (prereqs == null || prereqs.isEmpty()) return null; + List parts = new ArrayList<>(); + for (Prerequisite p : prereqs) { + if (p instanceof Prerequisite.QuestCompleted qc) { + parts.add("Quête « " + questNames.getOrDefault(qc.questId(), "?") + " » terminée"); + } else if (p instanceof Prerequisite.SessionReached sr) { + parts.add("Séance " + sr.minSessionNumber() + " atteinte"); + } else if (p instanceof Prerequisite.FlagSet fs) { + parts.add("Fait : " + fs.flagName()); + } + } + return parts.isEmpty() ? null : String.join("\n", parts); + } + + /** Nœuds narratifs (chapitres / scènes) traversés par une quête, en texte lisible. */ + private String renderQuestNodes(List nodes, Map chapterNames, Map sceneNames) { + if (nodes == null || nodes.isEmpty()) return null; + List parts = new ArrayList<>(); + for (QuestNodeRef n : nodes) { + if (n.nodeType() == NodeType.SCENE) { + parts.add("Scène : " + sceneNames.getOrDefault(n.nodeId(), "?")); + } else { + parts.add("Chapitre : " + chapterNames.getOrDefault(n.nodeId(), "?")); + } + } + return parts.isEmpty() ? null : String.join("\n", parts); + } + // ----- PNJ / Ennemis (groupes par dossier) ----- /** Une fiche persona generique pour la mise en page (PNJ ou ennemi). */ diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignReadinessController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignReadinessController.java new file mode 100644 index 0000000..a1484fa --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignReadinessController.java @@ -0,0 +1,39 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.CampaignReadinessAssessment; +import com.loremind.application.campaigncontext.CampaignReadinessService; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST Controller du Pilier B (« guidage / readiness »). Expose le bilan de + * préparation d'une campagne : statut agrégé + liste des manques cliquables. + * + *

Read-model pur, toujours 200 quand la campagne existe (même DRAFT / gaps + * critiques : le guidage informe, il ne bloque rien). 404 si la campagne est inconnue.

+ */ +@RestController +@RequestMapping("/api/campaigns/{campaignId}/readiness") +public class CampaignReadinessController { + + private final CampaignReadinessService readinessService; + private final CampaignRepository campaignRepository; + + public CampaignReadinessController(CampaignReadinessService readinessService, + CampaignRepository campaignRepository) { + this.readinessService = readinessService; + this.campaignRepository = campaignRepository; + } + + @GetMapping + public ResponseEntity getReadiness(@PathVariable String campaignId) { + if (!campaignRepository.existsById(campaignId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(readinessService.assess(campaignId)); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignTreeController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignTreeController.java new file mode 100644 index 0000000..0aae56a --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignTreeController.java @@ -0,0 +1,148 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.CampaignReadinessAssessment; +import com.loremind.application.campaigncontext.CampaignReadinessService; +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Enemy; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.EnemyRepository; +import com.loremind.domain.campaigncontext.ports.NpcRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.domain.campaigncontext.ports.RandomTableRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import com.loremind.infrastructure.web.dto.campaigncontext.ArcDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.SceneDTO; +import com.loremind.infrastructure.web.mapper.ArcMapper; +import com.loremind.infrastructure.web.mapper.ChapterMapper; +import com.loremind.infrastructure.web.mapper.NpcMapper; +import com.loremind.infrastructure.web.mapper.QuestMapper; +import com.loremind.infrastructure.web.mapper.RandomTableMapper; +import com.loremind.infrastructure.web.mapper.SceneMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Endpoint AGRÉGÉ de l'arbre de campagne : tout ce dont la sidebar a besoin en UNE + * requête HTTP — arcs → chapitres → scènes, PNJ, tables, ennemis, quêtes et readiness. + * + *

Remplace la rafale d'appels du front (~1 + N arcs + M chapitres + 5 listes ≈ 15-20 + * requêtes À CHAQUE navigation) : la latence HTTP par appel dominait le temps de rendu + * de la sidebar. Les DTO/formes JSON sont STRICTEMENT ceux des endpoints unitaires + * (mêmes mappers ; les ennemis sont sérialisés en domaine, comme {@code EnemyController}).

+ */ +@RestController +@RequestMapping("/api/campaigns/{campaignId}/tree") +public class CampaignTreeController { + + private final CampaignRepository campaignRepository; + private final ArcRepository arcRepository; + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + private final NpcRepository npcRepository; + private final RandomTableRepository randomTableRepository; + private final EnemyRepository enemyRepository; + private final QuestRepository questRepository; + private final CampaignReadinessService readinessService; + private final ArcMapper arcMapper; + private final ChapterMapper chapterMapper; + private final SceneMapper sceneMapper; + private final NpcMapper npcMapper; + private final RandomTableMapper randomTableMapper; + private final QuestMapper questMapper; + + public CampaignTreeController(CampaignRepository campaignRepository, + ArcRepository arcRepository, + ChapterRepository chapterRepository, + SceneRepository sceneRepository, + NpcRepository npcRepository, + RandomTableRepository randomTableRepository, + EnemyRepository enemyRepository, + QuestRepository questRepository, + CampaignReadinessService readinessService, + ArcMapper arcMapper, + ChapterMapper chapterMapper, + SceneMapper sceneMapper, + NpcMapper npcMapper, + RandomTableMapper randomTableMapper, + QuestMapper questMapper) { + this.campaignRepository = campaignRepository; + this.arcRepository = arcRepository; + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + this.npcRepository = npcRepository; + this.randomTableRepository = randomTableRepository; + this.enemyRepository = enemyRepository; + this.questRepository = questRepository; + this.readinessService = readinessService; + this.arcMapper = arcMapper; + this.chapterMapper = chapterMapper; + this.sceneMapper = sceneMapper; + this.npcMapper = npcMapper; + this.randomTableMapper = randomTableMapper; + this.questMapper = questMapper; + } + + /** + * Payload complet de la sidebar. {@code chaptersByArc}/{@code scenesByChapter} + * reprennent la forme que le front construisait lui-même ({@code CampaignTreeData}). + */ + public record CampaignTreeDTO( + List arcs, + Map> chaptersByArc, + Map> scenesByChapter, + List npcs, + List randomTables, + List enemies, + List quests, + CampaignReadinessAssessment readiness + ) {} + + @GetMapping + public ResponseEntity getTree(@PathVariable String campaignId) { + if (!campaignRepository.existsById(campaignId)) { + return ResponseEntity.notFound().build(); + } + + List arcs = arcRepository.findByCampaignId(campaignId); + Map> chaptersByArc = new HashMap<>(); + Map> scenesByChapter = new HashMap<>(); + for (Arc arc : arcs) { + List chapters = chapterRepository.findByArcId(arc.getId()); + chaptersByArc.put(arc.getId(), + chapters.stream().map(chapterMapper::toDTO).collect(Collectors.toList())); + for (Chapter chapter : chapters) { + scenesByChapter.put(chapter.getId(), + sceneRepository.findByChapterId(chapter.getId()).stream() + .map(sceneMapper::toDTO).collect(Collectors.toList())); + } + } + + return ResponseEntity.ok(new CampaignTreeDTO( + arcs.stream().map(arcMapper::toDTO).collect(Collectors.toList()), + chaptersByArc, + scenesByChapter, + npcRepository.findByCampaignId(campaignId).stream() + .map(npcMapper::toDTO).collect(Collectors.toList()), + randomTableRepository.findByCampaignId(campaignId).stream() + .map(randomTableMapper::toDTO).collect(Collectors.toList()), + enemyRepository.findByCampaignId(campaignId), + questRepository.findByCampaignId(campaignId).stream() + .map(questMapper::toDTO).collect(Collectors.toList()), + readinessService.assess(campaignId))); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java index c347967..6060c43 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java @@ -1,7 +1,6 @@ package com.loremind.infrastructure.web.controller; import com.loremind.application.campaigncontext.ChapterService; -import com.loremind.application.campaigncontext.ChapterStatusEnricher; import com.loremind.domain.campaigncontext.Chapter; import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO; import com.loremind.infrastructure.web.mapper.ChapterMapper; @@ -13,8 +12,10 @@ import java.util.stream.Collectors; /** * REST Controller pour le contexte Chapter. - * Si {@code ?playthroughId=} est fourni, les DTOs renvoyés sont enrichis de leur - * {@code progressionStatus} et {@code effectiveStatus} relatifs à ce Playthrough. + * + *

Depuis le Niveau 1, le Chapitre est une donnée de SCÉNARIO pure : plus de prérequis + * ni de statut de progression (le gating et la progression vivent sur les Quêtes). Les + * endpoints GET n'enrichissent donc plus rien à partir d'un {@code playthroughId}.

*/ @RestController @RequestMapping("/api/chapters") @@ -22,14 +23,10 @@ public class ChapterController { private final ChapterService chapterService; private final ChapterMapper chapterMapper; - private final ChapterStatusEnricher statusEnricher; - public ChapterController(ChapterService chapterService, - ChapterMapper chapterMapper, - ChapterStatusEnricher statusEnricher) { + public ChapterController(ChapterService chapterService, ChapterMapper chapterMapper) { this.chapterService = chapterService; this.chapterMapper = chapterMapper; - this.statusEnricher = statusEnricher; } @PostMapping @@ -39,34 +36,21 @@ public class ChapterController { } @GetMapping("/{id}") - public ResponseEntity getChapterById( - @PathVariable String id, - @RequestParam(value = "playthroughId", required = false) String playthroughId) { + public ResponseEntity getChapterById(@PathVariable String id) { return chapterService.getChapterById(id) - .map(chapter -> { - ChapterDTO dto = chapterMapper.toDTO(chapter); - if (playthroughId != null && !playthroughId.isBlank()) { - statusEnricher.enrich(List.of(dto), List.of(chapter), playthroughId); - } - return ResponseEntity.ok(dto); - }) + .map(chapter -> ResponseEntity.ok(chapterMapper.toDTO(chapter))) .orElse(ResponseEntity.notFound().build()); } @GetMapping public ResponseEntity> getAllChapters( - @RequestParam(value = "arcId", required = false) String arcId, - @RequestParam(value = "playthroughId", required = false) String playthroughId) { + @RequestParam(value = "arcId", required = false) String arcId) { List chapters = (arcId != null && !arcId.isBlank()) ? chapterService.getChaptersByArcId(arcId) : chapterService.getAllChapters(); List chapterDTOs = chapters.stream() .map(chapterMapper::toDTO) .collect(Collectors.toList()); - - if (playthroughId != null && !playthroughId.isBlank()) { - statusEnricher.enrich(chapterDTOs, chapters, playthroughId); - } return ResponseEntity.ok(chapterDTOs); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ClockController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ClockController.java new file mode 100644 index 0000000..cae8a3b --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ClockController.java @@ -0,0 +1,88 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.playcontext.ClockService; +import com.loremind.domain.playcontext.Clock; +import com.loremind.domain.playcontext.ClockTrigger; +import com.loremind.infrastructure.web.dto.playcontext.ClockDTO; +import com.loremind.infrastructure.web.mapper.ClockMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * API REST des Horloges de progression (Clocks) d'une Partie (Play Context). + * Ressource imbriquée sous le Playthrough, comme {@code quest-progressions}. + */ +@RestController +@RequestMapping("/api/playthroughs/{playthroughId}/clocks") +public class ClockController { + + /** Taille par défaut d'une horloge si non précisée. */ + private static final int DEFAULT_SEGMENTS = 4; + + private final ClockService clockService; + private final ClockMapper clockMapper; + + public ClockController(ClockService clockService, ClockMapper clockMapper) { + this.clockService = clockService; + this.clockMapper = clockMapper; + } + + public record ClockRequest(String name, String description, Integer segments, + String triggerType, String triggerRef, String frontId) {} + + @GetMapping + public ResponseEntity> list(@PathVariable String playthroughId) { + List dtos = clockService.getByPlaythrough(playthroughId).stream() + .map(clockMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @PostMapping + public ResponseEntity create(@PathVariable String playthroughId, @RequestBody ClockRequest req) { + Clock created = clockService.create(playthroughId, req.name(), req.description(), segmentsOr(req), + parseTrigger(req.triggerType()), req.triggerRef(), req.frontId()); + return ResponseEntity.ok(clockMapper.toDTO(created)); + } + + @PutMapping("/{clockId}") + public ResponseEntity update(@PathVariable String playthroughId, @PathVariable String clockId, + @RequestBody ClockRequest req) { + Clock updated = clockService.update(clockId, req.name(), req.description(), segmentsOr(req), + parseTrigger(req.triggerType()), req.triggerRef(), req.frontId()); + return ResponseEntity.ok(clockMapper.toDTO(updated)); + } + + @PutMapping("/{clockId}/advance") + public ResponseEntity advance(@PathVariable String playthroughId, @PathVariable String clockId) { + return ResponseEntity.ok(clockMapper.toDTO(clockService.advance(clockId))); + } + + @PutMapping("/{clockId}/regress") + public ResponseEntity regress(@PathVariable String playthroughId, @PathVariable String clockId) { + return ResponseEntity.ok(clockMapper.toDTO(clockService.regress(clockId))); + } + + @DeleteMapping("/{clockId}") + public ResponseEntity delete(@PathVariable String playthroughId, @PathVariable String clockId) { + clockService.delete(clockId); + return ResponseEntity.noContent().build(); + } + + private int segmentsOr(ClockRequest req) { + return req.segments() != null ? req.segments() : DEFAULT_SEGMENTS; + } + + /** Parse tolérant du type de déclencheur : null / valeur inconnue -> NONE. */ + private ClockTrigger parseTrigger(String value) { + if (value == null || value.isBlank()) return ClockTrigger.NONE; + try { + return ClockTrigger.valueOf(value); + } catch (IllegalArgumentException ex) { + return ClockTrigger.NONE; + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryExportController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryExportController.java index b007735..10ba265 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryExportController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryExportController.java @@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; @@ -33,12 +34,21 @@ public class FoundryExportController { this.foundryExportService = foundryExportService; } + /** + * Perimetre optionnel (tout par defaut) : {@code ?maps=&journals=&tables=} — + * ex. cartes + ennemis seulement : {@code ?journals=false&tables=false}. + */ @GetMapping(produces = "application/zip") - public ResponseEntity export(@PathVariable String campaignId) { + public ResponseEntity export( + @PathVariable String campaignId, + @RequestParam(name = "maps", defaultValue = "true") boolean maps, + @RequestParam(name = "journals", defaultValue = "true") boolean journals, + @RequestParam(name = "tables", defaultValue = "true") boolean tables) { String exportedAt = Instant.now().toString(); BuiltBundle bundle; try { - bundle = foundryExportService.buildBundle(campaignId, exportedAt); + bundle = foundryExportService.buildBundle(campaignId, exportedAt, + new FoundryExportService.ExportOptions(maps, journals, tables)); } catch (NoSuchElementException e) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage()); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/FrontController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/FrontController.java new file mode 100644 index 0000000..ad7e9df --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/FrontController.java @@ -0,0 +1,57 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.playcontext.FrontService; +import com.loremind.domain.playcontext.Front; +import com.loremind.infrastructure.web.dto.playcontext.FrontDTO; +import com.loremind.infrastructure.web.mapper.FrontMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * API REST des Fronts (menaces regroupant des horloges) d'une Partie (Play Context). + * Ressource imbriquée sous le Playthrough, comme {@code clocks}. + */ +@RestController +@RequestMapping("/api/playthroughs/{playthroughId}/fronts") +public class FrontController { + + private final FrontService frontService; + private final FrontMapper frontMapper; + + public FrontController(FrontService frontService, FrontMapper frontMapper) { + this.frontService = frontService; + this.frontMapper = frontMapper; + } + + public record FrontRequest(String name, String description) {} + + @GetMapping + public ResponseEntity> list(@PathVariable String playthroughId) { + List dtos = frontService.getByPlaythrough(playthroughId).stream() + .map(frontMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @PostMapping + public ResponseEntity create(@PathVariable String playthroughId, @RequestBody FrontRequest req) { + Front created = frontService.create(playthroughId, req.name(), req.description()); + return ResponseEntity.ok(frontMapper.toDTO(created)); + } + + @PutMapping("/{frontId}") + public ResponseEntity update(@PathVariable String playthroughId, @PathVariable String frontId, + @RequestBody FrontRequest req) { + Front updated = frontService.update(frontId, req.name(), req.description()); + return ResponseEntity.ok(frontMapper.toDTO(updated)); + } + + @DeleteMapping("/{frontId}") + public ResponseEntity delete(@PathVariable String playthroughId, @PathVariable String frontId) { + frontService.delete(frontId); + return ResponseEntity.noContent().build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NarrativeAssistController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NarrativeAssistController.java new file mode 100644 index 0000000..5d06d84 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NarrativeAssistController.java @@ -0,0 +1,87 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.ArcService; +import com.loremind.application.campaigncontext.ChapterService; +import com.loremind.application.campaigncontext.SceneService; +import com.loremind.application.generationcontext.NarrativeAssistFieldsUseCase; +import com.loremind.domain.campaigncontext.EntityFieldPatchProposal; +import com.loremind.domain.campaigncontext.ports.NarrativeAssistException; +import com.loremind.infrastructure.web.mapper.ArcMapper; +import com.loremind.infrastructure.web.mapper.ChapterMapper; +import com.loremind.infrastructure.web.mapper.SceneMapper; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** + * REST Controller du Pilier A (co-création « propose → applique »), GÉNÉRIQUE par type + * d'entité narrative ({@code arc|chapter|scene}). + * + *

{@code /generate} produit une PROPOSITION de patch (non persistée) ; l'utilisateur + * révise champ par champ côté front, puis {@code /apply} reçoit le record filtré aux champs + * acceptés et patche l'entité via son service (patch ciblé anti-écrasement).

+ */ +@RestController +@RequestMapping("/api/assist/{entityType}/{entityId}") +public class NarrativeAssistController { + + private final NarrativeAssistFieldsUseCase assistUseCase; + private final SceneService sceneService; + private final ChapterService chapterService; + private final ArcService arcService; + private final SceneMapper sceneMapper; + private final ChapterMapper chapterMapper; + private final ArcMapper arcMapper; + + public NarrativeAssistController(NarrativeAssistFieldsUseCase assistUseCase, + SceneService sceneService, + ChapterService chapterService, + ArcService arcService, + SceneMapper sceneMapper, + ChapterMapper chapterMapper, + ArcMapper arcMapper) { + this.assistUseCase = assistUseCase; + this.sceneService = sceneService; + this.chapterService = chapterService; + this.arcService = arcService; + this.sceneMapper = sceneMapper; + this.chapterMapper = chapterMapper; + this.arcMapper = arcMapper; + } + + /** Génère une proposition d'étoffage (non persistée). {@code campaignId} facultatif (contexte). */ + @PostMapping("/generate") + public ResponseEntity generate(@PathVariable String entityType, + @PathVariable String entityId, + @RequestBody(required = false) GenerateRequest body) { + String campaignId = body != null ? body.campaignId() : null; + String instruction = body != null ? body.instruction() : null; + try { + return ResponseEntity.ok(assistUseCase.execute(entityType, entityId, campaignId, instruction)); + } catch (NarrativeAssistException e) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e); + } + } + + /** Applique les champs ACCEPTÉS (patch ciblé) sur l'entité du type demandé. */ + @PostMapping("/apply") + public ResponseEntity apply(@PathVariable String entityType, + @PathVariable String entityId, + @RequestBody EntityFieldPatchProposal proposal) { + String type = entityType == null ? "" : entityType.trim().toLowerCase(); + Object dto = switch (type) { + case "scene" -> sceneMapper.toDTO(sceneService.patchScene(entityId, proposal.fields())); + case "chapter" -> chapterMapper.toDTO(chapterService.patchChapter(entityId, proposal.fields())); + case "arc" -> arcMapper.toDTO(arcService.patchArc(entityId, proposal.fields())); + default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType); + }; + return ResponseEntity.ok(dto); + } + + public record GenerateRequest(String campaignId, String instruction) {} +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughFlagController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughFlagController.java index 3de2526..243991e 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughFlagController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughFlagController.java @@ -1,5 +1,6 @@ package com.loremind.infrastructure.web.controller; +import com.loremind.application.playcontext.ClockService; import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; import com.loremind.infrastructure.web.dto.playcontext.PlaythroughFlagDTO; import org.springframework.http.ResponseEntity; @@ -18,9 +19,11 @@ import java.util.Map; public class PlaythroughFlagController { private final PlaythroughFlagRepository repo; + private final ClockService clockService; - public PlaythroughFlagController(PlaythroughFlagRepository repo) { + public PlaythroughFlagController(PlaythroughFlagRepository repo, ClockService clockService) { this.repo = repo; + this.clockService = clockService; } @GetMapping @@ -35,7 +38,10 @@ public class PlaythroughFlagController { public ResponseEntity setFlag(@PathVariable String playthroughId, @PathVariable String name, @RequestBody PlaythroughFlagDTO body) { + boolean wasRaised = Boolean.TRUE.equals(repo.findByPlaythroughId(playthroughId).get(name)); repo.setFlag(playthroughId, name, body.isValue()); + // Co-MJ : le Fait vient de passer à vrai (transition) -> avancer les horloges liées. + if (body.isValue() && !wasRaised) clockService.onFlagRaised(playthroughId, name); return ResponseEntity.ok(new PlaythroughFlagDTO(name, body.isValue())); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java new file mode 100644 index 0000000..8d805bf --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java @@ -0,0 +1,98 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.QuestService; +import com.loremind.application.campaigncontext.QuestStatusEnricher; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO; +import com.loremind.infrastructure.web.mapper.QuestMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * REST Controller pour le contexte Quest (Niveau 1). Quêtes ORTHOGONALES à l'arbre, + * rattachées à la campagne (décision D2). + * + *

Si {@code ?playthroughId=} est fourni, les DTOs sont enrichis de leur + * {@code progressionStatus} et {@code effectiveStatus} relatifs à ce Playthrough.

+ */ +@RestController +@RequestMapping("/api/campaigns/{campaignId}/quests") +public class QuestController { + + private final QuestService questService; + private final QuestMapper questMapper; + private final QuestStatusEnricher statusEnricher; + + public QuestController(QuestService questService, + QuestMapper questMapper, + QuestStatusEnricher statusEnricher) { + this.questService = questService; + this.questMapper = questMapper; + this.statusEnricher = statusEnricher; + } + + @PostMapping + public ResponseEntity createQuest(@PathVariable String campaignId, + @RequestBody QuestDTO questDTO) { + Quest domain = questMapper.toDomain(questDTO); + domain.setCampaignId(campaignId); // le path fait autorité + Quest created = questService.createQuest(domain); + return ResponseEntity.ok(questMapper.toDTO(created)); + } + + @GetMapping + public ResponseEntity> listByCampaign( + @PathVariable String campaignId, + @RequestParam(value = "playthroughId", required = false) String playthroughId) { + List quests = questService.getQuestsByCampaignId(campaignId); + List dtos = quests.stream().map(questMapper::toDTO).collect(Collectors.toList()); + if (playthroughId != null && !playthroughId.isBlank()) { + statusEnricher.enrich(dtos, quests, playthroughId); + } + return ResponseEntity.ok(dtos); + } + + @GetMapping("/{questId}") + public ResponseEntity getQuestById( + @PathVariable String campaignId, + @PathVariable String questId, + @RequestParam(value = "playthroughId", required = false) String playthroughId) { + return questService.getQuestById(questId) + .map(quest -> { + QuestDTO dto = questMapper.toDTO(quest); + if (playthroughId != null && !playthroughId.isBlank()) { + statusEnricher.enrich(List.of(dto), List.of(quest), playthroughId); + } + return ResponseEntity.ok(dto); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @PutMapping("/{questId}") + public ResponseEntity updateQuest(@PathVariable String campaignId, + @PathVariable String questId, + @RequestBody QuestDTO questDTO) { + Quest domain = questMapper.toDomain(questDTO); + domain.setCampaignId(campaignId); + Quest updated = questService.updateQuest(questId, domain); + return ResponseEntity.ok(questMapper.toDTO(updated)); + } + + @DeleteMapping("/{questId}") + public ResponseEntity deleteQuest(@PathVariable String campaignId, @PathVariable String questId) { + questService.deleteQuest(questId); + return ResponseEntity.noContent().build(); + } + + /** Réordonne les quêtes de la campagne : order = position. */ + @PutMapping("/reorder") + public ResponseEntity reorder(@PathVariable String campaignId, @RequestBody ReorderRequest req) { + questService.reorderQuests(campaignId, req.orderedIds()); + return ResponseEntity.noContent().build(); + } + + public record ReorderRequest(List orderedIds) {} +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/QuestProgressionController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestProgressionController.java index 34d0745..16c33d7 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/QuestProgressionController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestProgressionController.java @@ -1,5 +1,6 @@ package com.loremind.infrastructure.web.controller; +import com.loremind.application.playcontext.ClockService; import com.loremind.domain.campaigncontext.ProgressionStatus; import com.loremind.domain.playcontext.ports.QuestProgressionRepository; import org.springframework.http.ResponseEntity; @@ -18,28 +19,30 @@ import java.util.Map; public class QuestProgressionController { private final QuestProgressionRepository repo; + private final ClockService clockService; - public QuestProgressionController(QuestProgressionRepository repo) { + public QuestProgressionController(QuestProgressionRepository repo, ClockService clockService) { this.repo = repo; + this.clockService = clockService; } public record SetStatusRequest(String status) {} /** - * GET : renvoie une map chapterId -> ProgressionStatus pour le Playthrough. + * GET : renvoie une map questId -> ProgressionStatus pour le Playthrough. * Pratique pour le front qui peut indexer puissamment. */ @GetMapping public ResponseEntity> list(@PathVariable String playthroughId) { Map out = new HashMap<>(); repo.findByPlaythroughId(playthroughId) - .forEach(qp -> out.put(qp.getChapterId(), qp.getStatus().name())); + .forEach(qp -> out.put(qp.getQuestId(), qp.getStatus().name())); return ResponseEntity.ok(out); } - @PutMapping("/{chapterId}") + @PutMapping("/{questId}") public ResponseEntity setStatus(@PathVariable String playthroughId, - @PathVariable String chapterId, + @PathVariable String questId, @RequestBody SetStatusRequest body) { ProgressionStatus parsed = ProgressionStatus.NOT_STARTED; if (body.status() != null && !body.status().isBlank()) { @@ -49,7 +52,12 @@ public class QuestProgressionController { return ResponseEntity.badRequest().build(); } } - repo.setStatus(playthroughId, chapterId, parsed); + boolean wasCompleted = repo.findCompletedQuestIdsByPlaythroughId(playthroughId).contains(questId); + repo.setStatus(playthroughId, questId, parsed); + // Co-MJ : la quête vient de passer à COMPLETED (transition) -> avancer les horloges liées. + if (parsed == ProgressionStatus.COMPLETED && !wasCompleted) { + clockService.onQuestCompleted(playthroughId, questId); + } return ResponseEntity.noContent().build(); } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SceneDraftController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SceneDraftController.java new file mode 100644 index 0000000..09b43e0 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SceneDraftController.java @@ -0,0 +1,80 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.ChapterService; +import com.loremind.application.campaigncontext.SceneService; +import com.loremind.application.generationcontext.DraftScenesUseCase; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneDraftProposal; +import com.loremind.domain.campaigncontext.ports.NarrativeAssistException; +import com.loremind.infrastructure.web.dto.campaigncontext.SceneDTO; +import com.loremind.infrastructure.web.mapper.SceneMapper; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * REST Controller du Pilier A (capacité « create ») : peuple un chapitre en scènes. + * + *

{@code /generate} produit une PROPOSITION d'ébauches (non persistée) ; l'utilisateur + * révise/coche côté front, puis {@code /apply} reçoit les ébauches retenues et crée les + * scènes dans le chapitre. Découplage propose/apply comme le reste du Pilier A.

+ */ +@RestController +@RequestMapping("/api/chapters/{chapterId}/draft-scenes") +public class SceneDraftController { + + private static final int DEFAULT_COUNT = 4; + + private final DraftScenesUseCase draftUseCase; + private final SceneService sceneService; + private final ChapterService chapterService; + private final SceneMapper sceneMapper; + + public SceneDraftController(DraftScenesUseCase draftUseCase, + SceneService sceneService, + ChapterService chapterService, + SceneMapper sceneMapper) { + this.draftUseCase = draftUseCase; + this.sceneService = sceneService; + this.chapterService = chapterService; + this.sceneMapper = sceneMapper; + } + + /** Génère une proposition d'ébauches de scènes (non persistée). */ + @PostMapping("/generate") + public ResponseEntity generate(@PathVariable String chapterId, + @RequestBody(required = false) GenerateRequest body) { + String campaignId = body != null ? body.campaignId() : null; + String instruction = body != null ? body.instruction() : null; + int count = body != null && body.count() != null ? body.count() : DEFAULT_COUNT; + try { + return ResponseEntity.ok(draftUseCase.execute(chapterId, campaignId, instruction, count)); + } catch (NarrativeAssistException e) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e); + } + } + + /** Crée les scènes ACCEPTÉES dans le chapitre ; renvoie les scènes créées. */ + @PostMapping("/apply") + public ResponseEntity> apply(@PathVariable String chapterId, + @RequestBody SceneDraftProposal proposal) { + // Garde-fou : ne jamais créer de scènes orphelines si le chapitre n'existe pas + // (l'apply ne passe pas par le use case, contrairement au /generate). + if (!chapterService.chapterExists(chapterId)) { + throw new IllegalArgumentException("Chapitre non trouvé: " + chapterId); + } + List created = sceneService.createDraftScenes(chapterId, proposal.scenes()); + List dtos = created.stream().map(sceneMapper::toDTO).collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + public record GenerateRequest(String campaignId, String instruction, Integer count) {} +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java index 064377f..edd1095 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java @@ -1,11 +1,15 @@ package com.loremind.infrastructure.web.controller; +import com.loremind.application.playcontext.SessionRecapService; import com.loremind.application.playcontext.SessionService; import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.ports.SessionRecapException; import com.loremind.infrastructure.web.dto.playcontext.SessionDTO; import com.loremind.infrastructure.web.mapper.SessionMapper; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.stream.Collectors; @@ -19,10 +23,14 @@ import java.util.stream.Collectors; public class SessionController { private final SessionService sessionService; + private final SessionRecapService recapService; private final SessionMapper sessionMapper; - public SessionController(SessionService sessionService, SessionMapper sessionMapper) { + public SessionController(SessionService sessionService, + SessionRecapService recapService, + SessionMapper sessionMapper) { this.sessionService = sessionService; + this.recapService = recapService; this.sessionMapper = sessionMapper; } @@ -30,6 +38,8 @@ public class SessionController { public record RenameSessionRequest(String name) {} + public record CurrentSceneRequest(String sceneId) {} + @PostMapping public ResponseEntity startSession(@RequestBody StartSessionRequest request) { Session session = sessionService.startSession(request.playthroughId()); @@ -83,4 +93,22 @@ public class SessionController { sessionService.deleteSession(id); return ResponseEntity.noContent().build(); } + + /** Épingle (sceneId) ou dés-épingle (null/vide) la scène courante — mode cockpit. */ + @PutMapping("/{id}/current-scene") + public ResponseEntity setCurrentScene(@PathVariable String id, + @RequestBody CurrentSceneRequest request) { + Session updated = sessionService.setCurrentScene(id, request.sceneId()); + return ResponseEntity.ok(sessionMapper.toDTO(updated)); + } + + /** Récap « précédemment… » : résume le journal de la séance précédente de la même Partie. */ + @PostMapping("/{id}/recap") + public ResponseEntity recap(@PathVariable String id) { + try { + return ResponseEntity.ok(recapService.recapPreviousSession(id)); + } catch (SessionRecapException e) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e); + } + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SessionPrepController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionPrepController.java new file mode 100644 index 0000000..ba9b264 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionPrepController.java @@ -0,0 +1,37 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.playcontext.SessionPrepReport; +import com.loremind.application.playcontext.SessionPrepService; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST Controller « Préparer la prochaine séance » (Phase 3 co-MJ). Read-model pur : + * position des joueurs + contenu probable + manques ciblés + horloges en mouvement. + * 404 si la Partie est inconnue (calqué sur {@code CampaignReadinessController}). + */ +@RestController +@RequestMapping("/api/playthroughs/{playthroughId}/session-prep") +public class SessionPrepController { + + private final SessionPrepService sessionPrepService; + private final PlaythroughRepository playthroughRepository; + + public SessionPrepController(SessionPrepService sessionPrepService, + PlaythroughRepository playthroughRepository) { + this.sessionPrepService = sessionPrepService; + this.playthroughRepository = playthroughRepository; + } + + @GetMapping + public ResponseEntity getPrep(@PathVariable String playthroughId) { + if (playthroughRepository.findById(playthroughId).isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(sessionPrepService.prepare(playthroughId)); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java index 3d961d1..9d49849 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java @@ -17,22 +17,6 @@ public class ChapterDTO { private String arcId; private int order; - /** Conditions de déblocage (ET logique). Donnée de SCÉNARIO. */ - private List prerequisites = new ArrayList<>(); - - /** - * Statut de progression — lecture seule, populé uniquement quand le client demande - * l'enrichissement pour un Playthrough donné (param ?playthroughId=). - * Valeurs : "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED". - */ - private String progressionStatus; - - /** - * Statut effectif calculé côté backend ("LOCKED" | "AVAILABLE" | "IN_PROGRESS" | "COMPLETED"). - * Read-only — populé en même temps que {@code progressionStatus}. - */ - private String effectiveStatus; - /** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */ private String icon; diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/QuestDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/QuestDTO.java new file mode 100644 index 0000000..d4e7af1 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/QuestDTO.java @@ -0,0 +1,51 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; +import java.util.ArrayList; +import java.util.List; + +/** + * DTO pour l'entité Quest (API REST). Calqué sur {@code ChapterDTO}. + */ +@Data +public class QuestDTO { + + private String id; + private String campaignId; + /** Arc de rattachement (nullable). Non nul ⇒ quête d'un arc HUB ; null ⇒ transverse. */ + private String arcId; + private String name; + private String description; + private String icon; + private int order; + + /** Conditions de déblocage (ET logique). Donnée de SCÉNARIO. */ + private List prerequisites = new ArrayList<>(); + + /** Nœuds narratifs (Chapitres / Scènes) traversés par la quête. */ + private List nodes = new ArrayList<>(); + + /** + * Statut de progression — lecture seule, populé uniquement quand le client demande + * l'enrichissement pour un Playthrough donné (param ?playthroughId=). + * Valeurs : "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED". + */ + private String progressionStatus; + + /** + * Statut effectif calculé côté backend ("LOCKED" | "AVAILABLE" | "IN_PROGRESS" | "COMPLETED"). + * Read-only — populé en même temps que {@code progressionStatus}. + */ + private String effectiveStatus; + + // Champs narratifs + private String gmNotes; + private String playerObjectives; + private String narrativeStakes; + + /** IDs des pages du Lore liées (weak cross-context references). */ + private List relatedPageIds = new ArrayList<>(); + + /** IDs des images (Shared Kernel) illustrant la quête. */ + private List illustrationImageIds = new ArrayList<>(); +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/QuestNodeRefDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/QuestNodeRefDTO.java new file mode 100644 index 0000000..c974236 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/QuestNodeRefDTO.java @@ -0,0 +1,24 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO d'un lien Quête → nœud narratif (Chapitre ou Scène). + * Miroir REST du VO {@code QuestNodeRef}. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class QuestNodeRefDTO { + + /** Type du nœud : CHAPTER | SCENE. */ + private String nodeType; + + /** ID du Chapitre ou de la Scène (weak ref). */ + private String nodeId; + + /** Ordre du nœud dans la quête. */ + private int order; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBattlemapDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBattlemapDTO.java new file mode 100644 index 0000000..f828fe9 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBattlemapDTO.java @@ -0,0 +1,24 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +/** + * DTO pour une battlemap d'une Scene (variante Jour/Nuit, étage…). + * Pendant web du Value Object domaine SceneBattlemap. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SceneBattlemapDTO { + + /** Libellé libre de la variante (ex : "Jour", "Nuit"). Peut être vide. */ + private String label; + + /** ID du fichier media (image/video). Null = carte sans fond. */ + private String mediaFileId; + + /** ID du fichier sidecar Universal VTT (json/dd2vtt). Null si absent. */ + private String dataFileId; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBranchDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBranchDTO.java index c0288e0..b50c9f4 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBranchDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBranchDTO.java @@ -19,4 +19,7 @@ public class SceneBranchDTO { private String label; private String targetSceneId; private String condition; + + /** Type de lien (Niveau 2) : "EXIT" | "CLUE" | "LEAD". Absent => EXIT côté domaine. */ + private String kind; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java index 9f3643e..7b59f55 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java @@ -20,6 +20,9 @@ public class SceneDTO { /** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */ private String icon; + /** Type narratif du nœud (Niveau 2) : "GENERIC" | "LOCATION" | "ENCOUNTER" | "NPC" | "EVENT" | "REVELATION". */ + private String type; + // Champs narratifs enrichis private String location; private String timing; @@ -39,11 +42,12 @@ public class SceneDTO { /** IDs des images (Shared Kernel) illustrant cette scene (ambiance). */ private List illustrationImageIds = new ArrayList<>(); - /** Battlemap Foundry : ID du fichier media (image/video). Null = pas de carte. */ - private String battlemapMediaFileId; + /** Battlemaps Foundry : variantes étiquetées { label, media, sidecar }. Vide = pas de carte. */ + private List battlemaps = new ArrayList<>(); - /** Battlemap Foundry : ID du fichier sidecar Universal VTT (json). Null si absent. */ - private String battlemapDataFileId; + /** Position du nœud dans la vue graphe du chapitre (Niveau 2). Null = layout auto. */ + private Double graphX; + private Double graphY; /** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */ private List branches = new ArrayList<>(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/ClockDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/ClockDTO.java new file mode 100644 index 0000000..a550c3a --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/ClockDTO.java @@ -0,0 +1,30 @@ +package com.loremind.infrastructure.web.dto.playcontext; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * DTO pour l'entité Clock — objet de transfert de l'API REST. + */ +@Data +public class ClockDTO { + + private String id; + private String playthroughId; + private String name; + private String description; + private int segments; + private int filled; + private int order; + /** Déclencheur auto (co-MJ) : "NONE" | "FLAG_SET" | "QUEST_COMPLETED" | "SESSION_ENDED". */ + private String triggerType; + /** Cible du déclencheur : nom du fait (FLAG_SET) ou id de quête (QUEST_COMPLETED) ; sinon null. */ + private String triggerRef; + /** Front (menace) auquel l'horloge appartient, ou null (horloge libre). */ + private String frontId; + /** Read-only : {@code filled >= segments}. */ + private boolean complete; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/FrontDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/FrontDTO.java new file mode 100644 index 0000000..968b91d --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/FrontDTO.java @@ -0,0 +1,20 @@ +package com.loremind.infrastructure.web.dto.playcontext; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * DTO pour l'entité Front — objet de transfert de l'API REST. + */ +@Data +public class FrontDTO { + + private String id; + private String playthroughId; + private String name; + private String description; + private int order; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java index a93959e..eca2aef 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java @@ -16,6 +16,8 @@ public class SessionDTO { private LocalDateTime startedAt; /** Null = session en cours. */ private LocalDateTime endedAt; + /** Scène courante épinglée (nullable, mode cockpit). */ + private String currentSceneId; private LocalDateTime createdAt; private LocalDateTime updatedAt; private boolean active; diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java index f183914..d703813 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java @@ -10,19 +10,12 @@ import java.util.List; /** * Mapper Chapter (domaine) ↔ ChapterDTO (REST). * - *

Ne touche plus à {@code progressionStatus} ni {@code effectiveStatus} : - * ces champs sont propres à un Playthrough et injectés par {@code ChapterStatusEnricher} - * quand le controller a un playthroughId.

+ *

Depuis le Niveau 1, le Chapitre est une donnée de SCÉNARIO pure : plus de + * prérequis ni de statut de progression (le gating vit sur les Quêtes).

*/ @Component public class ChapterMapper { - private final PrerequisiteMapper prerequisiteMapper; - - public ChapterMapper(PrerequisiteMapper prerequisiteMapper) { - this.prerequisiteMapper = prerequisiteMapper; - } - public ChapterDTO toDTO(Chapter chapter) { if (chapter == null) return null; @@ -32,9 +25,6 @@ public class ChapterMapper { dto.setDescription(chapter.getDescription()); dto.setArcId(chapter.getArcId()); dto.setOrder(chapter.getOrder()); - dto.setPrerequisites(prerequisiteMapper.toDTOList(chapter.getPrerequisites())); - // progressionStatus / effectiveStatus : laissés null. Peuplés par ChapterStatusEnricher.enrich(...) - // si le client a fourni un playthroughId au controller. dto.setIcon(chapter.getIcon()); dto.setGmNotes(chapter.getGmNotes()); dto.setPlayerObjectives(chapter.getPlayerObjectives()); @@ -53,7 +43,6 @@ public class ChapterMapper { .description(dto.getDescription()) .arcId(dto.getArcId()) .order(dto.getOrder()) - .prerequisites(prerequisiteMapper.toDomainList(dto.getPrerequisites())) .icon(dto.getIcon()) .gmNotes(dto.getGmNotes()) .playerObjectives(dto.getPlayerObjectives()) diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/ClockMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/ClockMapper.java new file mode 100644 index 0000000..87b7542 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/ClockMapper.java @@ -0,0 +1,32 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.playcontext.Clock; +import com.loremind.domain.playcontext.ClockTrigger; +import com.loremind.infrastructure.web.dto.playcontext.ClockDTO; +import org.springframework.stereotype.Component; + +/** + * Mapper Clock (domaine) ↔ ClockDTO (transport REST). + */ +@Component +public class ClockMapper { + + public ClockDTO toDTO(Clock c) { + if (c == null) return null; + ClockDTO dto = new ClockDTO(); + dto.setId(c.getId()); + dto.setPlaythroughId(c.getPlaythroughId()); + dto.setName(c.getName()); + dto.setDescription(c.getDescription()); + dto.setSegments(c.getSegments()); + dto.setFilled(c.getFilled()); + dto.setOrder(c.getOrder()); + dto.setTriggerType((c.getTriggerType() != null ? c.getTriggerType() : ClockTrigger.NONE).name()); + dto.setTriggerRef(c.getTriggerRef()); + dto.setFrontId(c.getFrontId()); + dto.setComplete(c.isComplete()); + dto.setCreatedAt(c.getCreatedAt()); + dto.setUpdatedAt(c.getUpdatedAt()); + return dto; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/FrontMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/FrontMapper.java new file mode 100644 index 0000000..3851ec9 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/FrontMapper.java @@ -0,0 +1,25 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.playcontext.Front; +import com.loremind.infrastructure.web.dto.playcontext.FrontDTO; +import org.springframework.stereotype.Component; + +/** + * Mapper Front (domaine) ↔ FrontDTO (transport REST). + */ +@Component +public class FrontMapper { + + public FrontDTO toDTO(Front f) { + if (f == null) return null; + FrontDTO dto = new FrontDTO(); + dto.setId(f.getId()); + dto.setPlaythroughId(f.getPlaythroughId()); + dto.setName(f.getName()); + dto.setDescription(f.getDescription()); + dto.setOrder(f.getOrder()); + dto.setCreatedAt(f.getCreatedAt()); + dto.setUpdatedAt(f.getUpdatedAt()); + return dto; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/QuestMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/QuestMapper.java new file mode 100644 index 0000000..735a8f5 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/QuestMapper.java @@ -0,0 +1,92 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.QuestNodeRefDTO; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Mapper Quest (domaine) ↔ QuestDTO (REST). Calqué sur {@code ChapterMapper} : + * délègue les prérequis à {@code PrerequisiteMapper} (RÉUTILISÉ) et mappe les + * nœuds. Laisse {@code progressionStatus} / {@code effectiveStatus} à null + * (peuplés par {@code QuestStatusEnricher} si un playthroughId est fourni). + */ +@Component +public class QuestMapper { + + private final PrerequisiteMapper prerequisiteMapper; + + public QuestMapper(PrerequisiteMapper prerequisiteMapper) { + this.prerequisiteMapper = prerequisiteMapper; + } + + public QuestDTO toDTO(Quest quest) { + if (quest == null) return null; + + QuestDTO dto = new QuestDTO(); + dto.setId(quest.getId()); + dto.setCampaignId(quest.getCampaignId()); + dto.setArcId(quest.getArcId()); + dto.setName(quest.getName()); + dto.setDescription(quest.getDescription()); + dto.setIcon(quest.getIcon()); + dto.setOrder(quest.getOrder()); + dto.setPrerequisites(prerequisiteMapper.toDTOList(quest.getPrerequisites())); + dto.setNodes(toNodeDTOList(quest.getNodes())); + dto.setGmNotes(quest.getGmNotes()); + dto.setPlayerObjectives(quest.getPlayerObjectives()); + dto.setNarrativeStakes(quest.getNarrativeStakes()); + dto.setRelatedPageIds(copyList(quest.getRelatedPageIds())); + dto.setIllustrationImageIds(copyList(quest.getIllustrationImageIds())); + return dto; + } + + public Quest toDomain(QuestDTO dto) { + if (dto == null) return null; + + return Quest.builder() + .id(dto.getId()) + .campaignId(dto.getCampaignId()) + .arcId(dto.getArcId()) + .name(dto.getName()) + .description(dto.getDescription()) + .icon(dto.getIcon()) + .order(dto.getOrder()) + .prerequisites(prerequisiteMapper.toDomainList(dto.getPrerequisites())) + .nodes(toNodeDomainList(dto.getNodes())) + .gmNotes(dto.getGmNotes()) + .playerObjectives(dto.getPlayerObjectives()) + .narrativeStakes(dto.getNarrativeStakes()) + .relatedPageIds(copyList(dto.getRelatedPageIds())) + .illustrationImageIds(copyList(dto.getIllustrationImageIds())) + .build(); + } + + private List toNodeDTOList(List list) { + if (list == null) return new ArrayList<>(); + List out = new ArrayList<>(list.size()); + for (QuestNodeRef n : list) { + out.add(new QuestNodeRefDTO(n.nodeType().name(), n.nodeId(), n.order())); + } + return out; + } + + private List toNodeDomainList(List list) { + if (list == null) return new ArrayList<>(); + List out = new ArrayList<>(list.size()); + for (QuestNodeRefDTO d : list) { + if (d == null || d.getNodeType() == null) continue; + out.add(new QuestNodeRef(NodeType.valueOf(d.getNodeType()), d.getNodeId(), d.getOrder())); + } + return out; + } + + private List copyList(List source) { + return source != null ? new ArrayList<>(source) : new ArrayList<>(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java index 87f50ad..c20e342 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java @@ -1,11 +1,15 @@ package com.loremind.infrastructure.web.mapper; +import com.loremind.domain.campaigncontext.LinkType; import com.loremind.domain.campaigncontext.Room; import com.loremind.domain.campaigncontext.RoomBranch; import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneBattlemap; import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.domain.campaigncontext.SceneType; import com.loremind.infrastructure.web.dto.campaigncontext.RoomBranchDTO; import com.loremind.infrastructure.web.dto.campaigncontext.RoomDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.SceneBattlemapDTO; import com.loremind.infrastructure.web.dto.campaigncontext.SceneBranchDTO; import com.loremind.infrastructure.web.dto.campaigncontext.SceneDTO; import org.springframework.stereotype.Component; @@ -32,6 +36,7 @@ public class SceneMapper { dto.setChapterId(scene.getChapterId()); dto.setOrder(scene.getOrder()); dto.setIcon(scene.getIcon()); + dto.setType((scene.getType() != null ? scene.getType() : SceneType.GENERIC).name()); dto.setLocation(scene.getLocation()); dto.setTiming(scene.getTiming()); dto.setAtmosphere(scene.getAtmosphere()); @@ -49,8 +54,9 @@ public class SceneMapper { dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null ? new ArrayList<>(scene.getIllustrationImageIds()) : new ArrayList<>()); - dto.setBattlemapMediaFileId(scene.getBattlemapMediaFileId()); - dto.setBattlemapDataFileId(scene.getBattlemapDataFileId()); + dto.setBattlemaps(toBattlemapDTOs(scene.getBattlemaps())); + dto.setGraphX(scene.getGraphX()); + dto.setGraphY(scene.getGraphY()); dto.setBranches(toBranchDTOs(scene.getBranches())); dto.setRooms(toRoomDTOs(scene.getRooms())); return dto; @@ -68,6 +74,7 @@ public class SceneMapper { .chapterId(dto.getChapterId()) .order(dto.getOrder()) .icon(dto.getIcon()) + .type(parseSceneType(dto.getType())) .location(dto.getLocation()) .timing(dto.getTiming()) .atmosphere(dto.getAtmosphere()) @@ -85,29 +92,66 @@ public class SceneMapper { .illustrationImageIds(dto.getIllustrationImageIds() != null ? new ArrayList<>(dto.getIllustrationImageIds()) : new ArrayList<>()) - .battlemapMediaFileId(dto.getBattlemapMediaFileId()) - .battlemapDataFileId(dto.getBattlemapDataFileId()) + .battlemaps(toBattlemapDomain(dto.getBattlemaps())) + .graphX(dto.getGraphX()) + .graphY(dto.getGraphY()) .branches(toBranchDomain(dto.getBranches())) .rooms(toRoomDomain(dto.getRooms())) .build(); } + // ─────────────── Mapping des battlemaps (VO <-> DTO) ─────────────── + + private List toBattlemapDTOs(List battlemaps) { + if (battlemaps == null) return new ArrayList<>(); + return battlemaps.stream() + .map(b -> new SceneBattlemapDTO(b.label(), b.mediaFileId(), b.dataFileId())) + .collect(Collectors.toList()); + } + + private List toBattlemapDomain(List dtos) { + if (dtos == null) return new ArrayList<>(); + return dtos.stream() + .map(d -> new SceneBattlemap(d.getLabel(), d.getMediaFileId(), d.getDataFileId())) + .collect(Collectors.toList()); + } + // ─────────────── Mapping des branches (VO <-> DTO) ─────────────── private List toBranchDTOs(List branches) { if (branches == null) return new ArrayList<>(); return branches.stream() - .map(b -> new SceneBranchDTO(b.label(), b.targetSceneId(), b.condition())) + .map(b -> new SceneBranchDTO(b.label(), b.targetSceneId(), b.condition(), b.kind().name())) .collect(Collectors.toList()); } private List toBranchDomain(List dtos) { if (dtos == null) return new ArrayList<>(); return dtos.stream() - .map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition())) + .map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition(), parseLinkType(d.getKind()))) .collect(Collectors.toList()); } + /** Parse tolérant d'un {@link SceneType} : null / valeur inconnue -> GENERIC. */ + private SceneType parseSceneType(String value) { + if (value == null) return SceneType.GENERIC; + try { + return SceneType.valueOf(value); + } catch (IllegalArgumentException ex) { + return SceneType.GENERIC; + } + } + + /** Parse tolérant d'un {@link LinkType} : null / valeur inconnue -> EXIT. */ + private LinkType parseLinkType(String value) { + if (value == null) return LinkType.EXIT; + try { + return LinkType.valueOf(value); + } catch (IllegalArgumentException ex) { + return LinkType.EXIT; + } + } + // ─────────────── Mapping des pièces (VO <-> DTO) ─────────────── private List toRoomDTOs(List rooms) { diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java index d219af6..b47413e 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java @@ -18,6 +18,7 @@ public class SessionMapper { dto.setPlaythroughId(session.getPlaythroughId()); dto.setStartedAt(session.getStartedAt()); dto.setEndedAt(session.getEndedAt()); + dto.setCurrentSceneId(session.getCurrentSceneId()); dto.setCreatedAt(session.getCreatedAt()); dto.setUpdatedAt(session.getUpdatedAt()); dto.setActive(session.isActive()); diff --git a/core/src/main/java/db/migration/V10__Backfill_quests_from_chapters.java b/core/src/main/java/db/migration/V10__Backfill_quests_from_chapters.java new file mode 100644 index 0000000..06123da --- /dev/null +++ b/core/src/main/java/db/migration/V10__Backfill_quests_from_chapters.java @@ -0,0 +1,69 @@ +package db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +/** + * Niveau 1 — Backfill des Quests depuis les chapitres existants (migration ADDITIVE). + * + *

Chaque chapitre qui jouait le rôle de quête devient une {@code Quest} avec le + * MÊME id (continuité d'id, décision D4 en migration in‑place). Critère aligné sur + * la règle runtime réelle ({@code SessionStructuralContextBuilder} : une quête = + * {@code arc HUB} OU {@code chapitre porteur de prérequis}), + filet « tout chapitre + * déjà référencé par une progression » pour ne perdre aucun état.

+ * + *

Comme l'id est partagé, les {@code Prerequisite.QuestCompleted.questId} (qui + * valaient un chapterId) restent valides verbatim, et la bascule ultérieure de + * {@code quest_progression.chapter_id → quest_id} sera une simple copie. Migration + * en Java (et non SQL) pour le seul point non portable : le reset de la séquence + * d'identité côté Postgres après insertion d'ids explicites.

+ */ +public class V10__Backfill_quests_from_chapters extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + Connection conn = context.getConnection(); + try (Statement st = conn.createStatement()) { + + // 1) Backfill : on réutilise l'id du chapitre comme id de quête. + // Le nœud d'origine (le chapitre) est ajouté comme premier QuestNodeRef. + st.executeUpdate( + "INSERT INTO quests (id, campaign_id, \"order\", name, description, icon, " + + "prerequisites, nodes, gm_notes, player_objectives, narrative_stakes, " + + "related_page_ids, illustration_image_ids, created_at, updated_at) " + + "SELECT c.id, a.campaign_id, c.\"order\", c.name, c.description, c.icon, " + + "c.prerequisites, " + + "'[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"' || CAST(c.id AS VARCHAR) || '\",\"order\":0}]', " + + "c.gm_notes, c.player_objectives, c.narrative_stakes, " + + "c.related_page_ids, c.illustration_image_ids, c.created_at, c.updated_at " + + "FROM chapters c JOIN arcs a ON c.arc_id = a.id " + + "WHERE a.type = 'HUB' " + + " OR (c.prerequisites IS NOT NULL AND c.prerequisites <> '' AND c.prerequisites <> '[]') " + + " OR c.id IN (SELECT chapter_id FROM quest_progression)" + ); + + // 2) Reset de la séquence d'identité de `quests`. + // Les ids ont été insérés EXPLICITEMENT (continuité avec les chapitres). + // Ni Postgres ni H2 (MODE=PostgreSQL) n'ajustent la séquence d'un + // GENERATED BY DEFAULT AS IDENTITY sur insertion explicite → sans reset, + // une Quest créée ensuite par l'appli réutiliserait un id déjà pris. + long nextId; + try (ResultSet rs = st.executeQuery("SELECT COALESCE(MAX(id), 0) FROM quests")) { + rs.next(); + nextId = rs.getLong(1) + 1; + } + String product = conn.getMetaData().getDatabaseProductName(); + boolean isPostgres = product != null && product.toLowerCase().contains("postgresql"); + if (isPostgres) { + st.execute("SELECT setval(pg_get_serial_sequence('quests', 'id'), " + nextId + ", false)"); + } else { + // H2 (MODE=PostgreSQL) : RESTART WITH repositionne la séquence d'identité. + st.execute("ALTER TABLE quests ALTER COLUMN id RESTART WITH " + nextId); + } + } + } +} diff --git a/core/src/main/java/db/migration/V21__Drop_arc_type_check_constraint.java b/core/src/main/java/db/migration/V21__Drop_arc_type_check_constraint.java new file mode 100644 index 0000000..45848f7 --- /dev/null +++ b/core/src/main/java/db/migration/V21__Drop_arc_type_check_constraint.java @@ -0,0 +1,48 @@ +package db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +/** + * Supprime la contrainte CHECK {@code type in ('LINEAR','HUB')} héritée du baseline V1 + * sur {@code arcs.type} : elle rejetterait le nouveau type {@code SYSTEM} (arc technique + * « Quêtes libres », conteneurs des quêtes hors arc). + * + *

Migration en Java car le NOM de la contrainte n'est pas portable : inline dans le + * baseline, il est auto-généré différemment par H2 (CONSTRAINT_xxx) et Postgres + * (arcs_type_check) — et différent encore sur les bases historiques créées par + * ddl-auto puis baselinées. On la retrouve donc par sa CLAUSE via + * {@code information_schema.check_constraints} ('LINEAR' n'apparaît dans aucune autre + * contrainte du schéma). No-op si aucune contrainte ne matche.

+ * + *

Pas de re-création avec la liste étendue : même politique que les enums ajoutés + * après le baseline (scene_type V13, clocks V15…), qui vivent sans CHECK — la valeur + * est bornée par l'enum Java.

+ */ +public class V21__Drop_arc_type_check_constraint extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + Connection conn = context.getConnection(); + List names = new ArrayList<>(); + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery( + "SELECT constraint_name FROM information_schema.check_constraints " + + "WHERE check_clause LIKE '%LINEAR%'")) { + while (rs.next()) { + names.add(rs.getString(1)); + } + } + try (Statement st = conn.createStatement()) { + for (String name : names) { + st.executeUpdate("ALTER TABLE arcs DROP CONSTRAINT \"" + name + "\""); + } + } + } +} diff --git a/core/src/main/resources/db/migration/V11__quest_progression_to_quest_id.sql b/core/src/main/resources/db/migration/V11__quest_progression_to_quest_id.sql new file mode 100644 index 0000000..02b9e7e --- /dev/null +++ b/core/src/main/resources/db/migration/V11__quest_progression_to_quest_id.sql @@ -0,0 +1,16 @@ + + -- Niveau 1, Phase 2b : quest_progression passe de chapter_id à quest_id. + -- Continuité d'id (quest id == chapter id pour les quêtes migrées) => simple copie, + -- aucune réécriture de valeur. Contraintes nommées => DROP portable H2 / Postgres. + + alter table quest_progression add column quest_id bigint; + + update quest_progression set quest_id = chapter_id; + + alter table quest_progression alter column quest_id set not null; + + alter table quest_progression drop constraint uk_quest_progression_unique; + + alter table quest_progression add constraint uk_quest_progression_quest unique (playthrough_id, quest_id); + + alter table quest_progression drop column chapter_id; diff --git a/core/src/main/resources/db/migration/V12__drop_chapter_prerequisites.sql b/core/src/main/resources/db/migration/V12__drop_chapter_prerequisites.sql new file mode 100644 index 0000000..2c0b198 --- /dev/null +++ b/core/src/main/resources/db/migration/V12__drop_chapter_prerequisites.sql @@ -0,0 +1,5 @@ +-- Niveau 1 : le Chapitre devient une donnée de SCÉNARIO pure. Les prérequis (conditions +-- de déblocage) vivent désormais uniquement sur la table quests ; la colonne des chapitres +-- est morte (la migration V10 a déjà recopié les chapitres « quête » HUB vers quests). +-- Compatible Postgres et H2 (MODE=PostgreSQL). Pas de FK ni d'index sur cette colonne. +alter table chapters drop column prerequisites; diff --git a/core/src/main/resources/db/migration/V13__add_scene_type.sql b/core/src/main/resources/db/migration/V13__add_scene_type.sql new file mode 100644 index 0000000..075b56e --- /dev/null +++ b/core/src/main/resources/db/migration/V13__add_scene_type.sql @@ -0,0 +1,6 @@ +-- Niveau 2 (graphe de nœuds typés) : la Scène devient un nœud narratif typé. +-- `scene_type` est une métadonnée (GENERIC | LOCATION | ENCOUNTER | NPC | EVENT | +-- REVELATION) qui n'altère pas le comportement existant des scènes. Les liens typés +-- (SceneBranch.kind) sont portés par le JSON de la colonne `branches` (champ additif, +-- pas de migration). Compatible Postgres et H2 (MODE=PostgreSQL). +alter table scenes add column scene_type varchar(32) not null default 'GENERIC'; diff --git a/core/src/main/resources/db/migration/V14__add_scene_graph_position.sql b/core/src/main/resources/db/migration/V14__add_scene_graph_position.sql new file mode 100644 index 0000000..a4e5228 --- /dev/null +++ b/core/src/main/resources/db/migration/V14__add_scene_graph_position.sql @@ -0,0 +1,5 @@ +-- Niveau 2 (2d) : positions persistées des nœuds dans la vue graphe d'un chapitre. +-- Null = nœud pas encore positionné → le layout automatique (BFS) le place à l'ouverture. +-- Compatible Postgres et H2 (MODE=PostgreSQL). +alter table scenes add column graph_x double precision; +alter table scenes add column graph_y double precision; diff --git a/core/src/main/resources/db/migration/V15__create_clocks.sql b/core/src/main/resources/db/migration/V15__create_clocks.sql new file mode 100644 index 0000000..6f24284 --- /dev/null +++ b/core/src/main/resources/db/migration/V15__create_clocks.sql @@ -0,0 +1,16 @@ +-- Émergence (horloges de progression « Blades in the Dark ») : compteurs par Partie. +-- État dynamique de jeu, rattaché au Playthrough (comme sessions / quest_progression). +-- Compatible Postgres et H2 (MODE=PostgreSQL). +create table clocks ( + id bigint generated by default as identity, + playthrough_id bigint not null, + name varchar(255) not null, + description text, + segments integer not null, + filled integer not null, + "order" integer not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + primary key (id) +); +create index ix_clock_playthrough on clocks (playthrough_id); diff --git a/core/src/main/resources/db/migration/V16__add_clock_trigger.sql b/core/src/main/resources/db/migration/V16__add_clock_trigger.sql new file mode 100644 index 0000000..b6ae977 --- /dev/null +++ b/core/src/main/resources/db/migration/V16__add_clock_trigger.sql @@ -0,0 +1,5 @@ +-- Co-MJ : une horloge peut s'avancer AUTOMATIQUEMENT quand un événement lié survient +-- (un Fait bascule / une quête se termine / une séance se clôt). triggerRef = nom du fait +-- ou id de quête selon le type. Compatible Postgres et H2 (MODE=PostgreSQL). +alter table clocks add column trigger_type varchar(32) not null default 'NONE'; +alter table clocks add column trigger_ref varchar(255); diff --git a/core/src/main/resources/db/migration/V17__create_fronts.sql b/core/src/main/resources/db/migration/V17__create_fronts.sql new file mode 100644 index 0000000..bb4f659 --- /dev/null +++ b/core/src/main/resources/db/migration/V17__create_fronts.sql @@ -0,0 +1,16 @@ +-- Fronts (menaces regroupant des horloges) par Partie + rattachement optionnel des horloges. +-- État de jeu, comme les horloges. Compatible Postgres et H2 (MODE=PostgreSQL). +create table fronts ( + id bigint generated by default as identity, + playthrough_id bigint not null, + name varchar(255) not null, + description text, + "order" integer not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + primary key (id) +); +create index ix_front_playthrough on fronts (playthrough_id); + +-- Une horloge peut appartenir à un Front (null = horloge libre). +alter table clocks add column front_id bigint; diff --git a/core/src/main/resources/db/migration/V18__add_arc_id_to_quests.sql b/core/src/main/resources/db/migration/V18__add_arc_id_to_quests.sql new file mode 100644 index 0000000..e4ef718 --- /dev/null +++ b/core/src/main/resources/db/migration/V18__add_arc_id_to_quests.sql @@ -0,0 +1,6 @@ +-- Rattachement d'une quête à un arc HUB (arc Linéaire = chapitres, arc Hub = quêtes). +-- Nullable : arcId null = quête TRANSVERSE (liste « Quêtes »). Pas de FK (agrégat Quest +-- indépendant, cohérent avec campaign_id). Portable H2 (MODE=PostgreSQL) + Postgres. +alter table quests add column arc_id bigint; + +create index ix_quests_arc on quests (arc_id); diff --git a/core/src/main/resources/db/migration/V19__session_current_scene.sql b/core/src/main/resources/db/migration/V19__session_current_scene.sql new file mode 100644 index 0000000..a04e331 --- /dev/null +++ b/core/src/main/resources/db/migration/V19__session_current_scene.sql @@ -0,0 +1,4 @@ +-- Mode séance (cockpit) : scène courante épinglée sur la session en cours. +-- Weak ref nullable vers scenes.id (pas de FK — la scène peut être supprimée, +-- l'épingle devient simplement caduque). Portable H2 (MODE=PostgreSQL) + Postgres. +alter table sessions add column current_scene_id bigint; diff --git a/core/src/main/resources/db/migration/V20__attach_legacy_hub_quests.sql b/core/src/main/resources/db/migration/V20__attach_legacy_hub_quests.sql new file mode 100644 index 0000000..d2c9e14 --- /dev/null +++ b/core/src/main/resources/db/migration/V20__attach_legacy_hub_quests.sql @@ -0,0 +1,20 @@ +-- Réconciliation « arc HUB = quêtes » pour les données migrées par V10. +-- V10 a créé, pour chaque chapitre d'arc HUB, une quête JUMELLE avec le MÊME id +-- (continuité d'id) — mais AVANT l'existence de quests.arc_id (V18) : ces quêtes +-- sont donc restées « transverses » et n'apparaissaient pas sous leur arc HUB. +-- On les rattache ici à l'arc de leur chapitre jumeau. Idempotent (arc_id is null), +-- portable H2 (MODE=PostgreSQL) + Postgres. +update quests +set arc_id = ( + select c.arc_id + from chapters c + join arcs a on c.arc_id = a.id + where c.id = quests.id and a.type = 'HUB' +) +where arc_id is null + and exists ( + select 1 + from chapters c + join arcs a on c.arc_id = a.id + where c.id = quests.id and a.type = 'HUB' + ); diff --git a/core/src/main/resources/db/migration/V22__scene_battlemaps_list.sql b/core/src/main/resources/db/migration/V22__scene_battlemaps_list.sql new file mode 100644 index 0000000..f713f5a --- /dev/null +++ b/core/src/main/resources/db/migration/V22__scene_battlemaps_list.sql @@ -0,0 +1,23 @@ +-- Battlemaps multiples par scène (variantes Jour/Nuit, étages…) : +-- la paire de colonnes { battlemap_media_file_id, battlemap_data_file_id } devient +-- une LISTE JSON [{label, mediaFileId, dataFileId}] dans une colonne TEXT +-- (même pattern que branches/rooms). La carte existante est reprise comme +-- première entrée, libellé vide. +-- Compatible H2 (MODE=PostgreSQL) et PostgreSQL : concaténation || et CASE. + +ALTER TABLE scenes ADD COLUMN battlemaps TEXT; + +UPDATE scenes SET battlemaps = + '[{"label":"","mediaFileId":' || + CASE WHEN battlemap_media_file_id IS NULL THEN 'null' + ELSE '"' || battlemap_media_file_id || '"' END || + ',"dataFileId":' || + CASE WHEN battlemap_data_file_id IS NULL THEN 'null' + ELSE '"' || battlemap_data_file_id || '"' END || + '}]' +WHERE battlemap_media_file_id IS NOT NULL OR battlemap_data_file_id IS NOT NULL; + +UPDATE scenes SET battlemaps = '[]' WHERE battlemaps IS NULL; + +ALTER TABLE scenes DROP COLUMN battlemap_media_file_id; +ALTER TABLE scenes DROP COLUMN battlemap_data_file_id; diff --git a/core/src/main/resources/db/migration/V9__create_quests.sql b/core/src/main/resources/db/migration/V9__create_quests.sql new file mode 100644 index 0000000..25f6c87 --- /dev/null +++ b/core/src/main/resources/db/migration/V9__create_quests.sql @@ -0,0 +1,22 @@ + + create table quests ( + "order" integer not null, + campaign_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + description TEXT, + gm_notes TEXT, + icon varchar(255), + illustration_image_ids TEXT, + name varchar(255) not null, + narrative_stakes TEXT, + nodes TEXT, + player_objectives TEXT, + prerequisites TEXT, + related_page_ids TEXT, + primary key (id) + ); + + create index ix_quests_campaign + on quests (campaign_id); diff --git a/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java index cc41e7e..32f4581 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java @@ -2,9 +2,11 @@ package com.loremind.application.campaigncontext; import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -35,6 +37,8 @@ public class ArcServiceTest { private ChapterRepository chapterRepository; @Mock private SceneRepository sceneRepository; + @Mock + private QuestRepository questRepository; @InjectMocks private ArcService arcService; @@ -52,6 +56,32 @@ public class ArcServiceTest { .build(); } + @Test + void patchArc_appliesOnlyProvidedFields_leavesOthersIntact() { + Arc existing = Arc.builder() + .id("arc-1").name("Nom").campaignId("campaign-1") + .description("desc orig").themes("th orig").gmNotes("secret").build(); + when(arcRepository.findById("arc-1")).thenReturn(Optional.of(existing)); + when(arcRepository.save(any(Arc.class))).thenAnswer(inv -> inv.getArgument(0)); + + Arc result = arcService.patchArc("arc-1", List.of( + new FieldProposal("themes", "th orig", "trahison, rédemption"), + new FieldProposal("name", "Nom", "HACK"))); // name non patchable + + assertEquals("trahison, rédemption", result.getThemes()); + assertEquals("desc orig", result.getDescription()); // intact + assertEquals("secret", result.getGmNotes()); // intact + assertEquals("Nom", result.getName()); // name hors whitelist + verify(arcRepository, times(1)).save(any(Arc.class)); + } + + @Test + void patchArc_notFound_throws() { + when(arcRepository.findById("nope")).thenReturn(Optional.empty()); + assertThrows(IllegalArgumentException.class, () -> arcService.patchArc("nope", List.of())); + verify(arcRepository, never()).save(any()); + } + @Test void testCreateArc() { // Arrange diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignReadinessServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignReadinessServiceTest.java new file mode 100644 index 0000000..d48c18e --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignReadinessServiceTest.java @@ -0,0 +1,404 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Enemy; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.domain.campaigncontext.ReadinessSeverity; +import com.loremind.domain.campaigncontext.ReadinessStatus; +import com.loremind.domain.campaigncontext.Room; +import com.loremind.domain.campaigncontext.RoomBranch; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.EnemyRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +/** + * Test unitaire pour {@link CampaignReadinessService} (Pilier B — guidage / readiness). + * Mocks des ports Campaign Context ; les stubs manquants renvoient des listes vides. + * Couvre chaque règle du périmètre MVP + l'agrégation de statut + le mode plat. + */ +@ExtendWith(MockitoExtension.class) +class CampaignReadinessServiceTest { + + private static final String CAMP = "camp"; + + @Mock private CampaignRepository campaignRepository; + @Mock private ArcRepository arcRepository; + @Mock private ChapterRepository chapterRepository; + @Mock private SceneRepository sceneRepository; + @Mock private QuestRepository questRepository; + @Mock private EnemyRepository enemyRepository; + + @InjectMocks private CampaignReadinessService service; + + // --- helpers --- + + private Arc arc(String id) { + return Arc.builder().id(id).name(id).campaignId(CAMP).order(0).build(); + } + + private Chapter chapter(String id, String arcId) { + return Chapter.builder().id(id).name(id).arcId(arcId).order(0).build(); + } + + private Scene scene(String id, String chapterId) { + return Scene.builder().id(id).name(id).chapterId(chapterId).order(0).build(); + } + + private Set ruleIds(CampaignReadinessAssessment a) { + return a.gaps().stream().map(ReadinessGap::ruleId).collect(Collectors.toSet()); + } + + /** Câble un arbre à un seul arc/chapitre/liste de scènes. */ + private void tree(String arcId, String chapterId, List scenes) { + when(arcRepository.findByCampaignId(CAMP)).thenReturn(List.of(arc(arcId))); + when(chapterRepository.findByArcId(arcId)).thenReturn(List.of(chapter(chapterId, arcId))); + when(sceneRepository.findByChapterId(chapterId)).thenReturn(scenes); + } + + // --- CAMP-001 : campagne vide --- + + @Test + void emptyCampaign_isDraft_withNoContentGap() { + // Tous les repos renvoient vide par défaut. + CampaignReadinessAssessment a = service.assess(CAMP); + + assertEquals(ReadinessStatus.DRAFT, a.overallStatus()); + assertTrue(ruleIds(a).contains("CAMP-001-NO-CONTENT")); + assertEquals(1, a.counts().get("BLOCKING")); + assertEquals(0, a.counts().get("RECOMMENDED")); + } + + @Test + void campaignWithOneScene_notEmpty_noContentGap() { + tree("arc-1", "chap-1", List.of(scene("scene-1", "chap-1"))); + + CampaignReadinessAssessment a = service.assess(CAMP); + + assertFalse(ruleIds(a).contains("CAMP-001-NO-CONTENT")); + assertEquals(ReadinessStatus.POLISHED, a.overallStatus()); + assertTrue(a.gaps().isEmpty()); + } + + // --- ARC-001 / CHAP-001 : vides --- + + @Test + void emptyArc_yieldsArcGap() { + when(arcRepository.findByCampaignId(CAMP)).thenReturn(List.of(arc("arc-1"))); + // chapterRepository.findByArcId -> vide par défaut + // Une quête porte le contenu pour ne pas déclencher CAMP-001. + Quest q = Quest.builder().id("q-1").name("Q").campaignId(CAMP) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-x", 0))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q)); + + CampaignReadinessAssessment a = service.assess(CAMP); + + assertTrue(ruleIds(a).contains("ARC-001-EMPTY")); + assertFalse(ruleIds(a).contains("CAMP-001-NO-CONTENT")); + } + + @Test + void hubArcWithAttachedQuest_isNotEmpty_noArcGap() { + // Arc HUB sans chapitre MAIS avec une quête rattachée (arcId = arc) → pas ARC-001. + Arc hub = Arc.builder().id("arc-h").name("Hub").campaignId(CAMP).type(ArcType.HUB).order(0).build(); + when(arcRepository.findByCampaignId(CAMP)).thenReturn(List.of(hub)); + Quest q = Quest.builder().id("q-1").name("Q").campaignId(CAMP).arcId("arc-h") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-x", 0))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q)); + + assertFalse(ruleIds(service.assess(CAMP)).contains("ARC-001-EMPTY")); + } + + @Test + void hubArcWithoutChapterNorQuest_yieldsArcGap() { + Arc hub = Arc.builder().id("arc-h").name("Hub").campaignId(CAMP).type(ArcType.HUB).order(0).build(); + when(arcRepository.findByCampaignId(CAMP)).thenReturn(List.of(hub)); + // Aucun chapitre, aucune quête (repos vides par défaut). + assertTrue(ruleIds(service.assess(CAMP)).contains("ARC-001-EMPTY")); + } + + @Test + void linearArcWithAttachedQuest_stillYieldsArcGap() { + // Les quêtes ne « sauvent » que les arcs HUB : un arc LINÉAIRE vide reste signalé, + // même si une quête pointe dessus (état incohérent non produit par l'UI, mais toléré). + Arc linear = Arc.builder().id("arc-l").name("Linear").campaignId(CAMP).type(ArcType.LINEAR).order(0).build(); + when(arcRepository.findByCampaignId(CAMP)).thenReturn(List.of(linear)); + Quest q = Quest.builder().id("q-1").name("Q").campaignId(CAMP).arcId("arc-l") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-x", 0))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q)); + + assertTrue(ruleIds(service.assess(CAMP)).contains("ARC-001-EMPTY")); + } + + @Test + void chapterWithoutScene_yieldsChapterGap() { + when(arcRepository.findByCampaignId(CAMP)).thenReturn(List.of(arc("arc-1"))); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter("chap-1", "arc-1"))); + // sceneRepository.findByChapterId -> vide par défaut + Quest q = Quest.builder().id("q-1").name("Q").campaignId(CAMP) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q)); + + CampaignReadinessAssessment a = service.assess(CAMP); + + assertTrue(ruleIds(a).contains("CHAP-001-NO-SCENE")); + } + + // --- SCENE-001 : sans titre --- + + @Test + void sceneWithoutName_yieldsBlockingGap() { + Scene s = Scene.builder().id("scene-1").name(" ").chapterId("chap-1").order(0).build(); + tree("arc-1", "chap-1", List.of(s)); + + CampaignReadinessAssessment a = service.assess(CAMP); + + assertTrue(ruleIds(a).contains("SCENE-001-NO-NAME")); + assertEquals(ReadinessStatus.DRAFT, a.overallStatus()); + } + + // --- SCENE-010 : branches --- + + @Test + void sceneBranchToGhost_isBroken() { + Scene s1 = Scene.builder().id("scene-1").name("S1").chapterId("chap-1").order(0) + .branches(List.of(SceneBranch.of("Aller", "ghost"))).build(); + Scene s2 = scene("scene-2", "chap-1"); + tree("arc-1", "chap-1", List.of(s1, s2)); + + assertTrue(ruleIds(service.assess(CAMP)).contains("SCENE-010-BRANCH-INVALID")); + } + + @Test + void sceneBranchToSelf_isBroken() { + Scene s1 = Scene.builder().id("scene-1").name("S1").chapterId("chap-1").order(0) + .branches(List.of(SceneBranch.of("Boucle", "scene-1"))).build(); + tree("arc-1", "chap-1", List.of(s1)); + + assertTrue(ruleIds(service.assess(CAMP)).contains("SCENE-010-BRANCH-INVALID")); + } + + @Test + void sceneBranchToSiblingInChapter_isValid() { + Scene s1 = Scene.builder().id("scene-1").name("S1").chapterId("chap-1").order(0) + .branches(List.of(SceneBranch.of("Aller", "scene-2"))).build(); + Scene s2 = scene("scene-2", "chap-1"); + tree("arc-1", "chap-1", List.of(s1, s2)); + + assertFalse(ruleIds(service.assess(CAMP)).contains("SCENE-010-BRANCH-INVALID")); + } + + // --- SCENE-011 : combat sans ennemi --- + + @Test + void combatWithoutEnemy_yieldsRecommendedGap_andPlayableStatus() { + Scene s = Scene.builder().id("scene-1").name("S1").chapterId("chap-1").order(0) + .combatDifficulty("Moyen").build(); + tree("arc-1", "chap-1", List.of(s)); + + CampaignReadinessAssessment a = service.assess(CAMP); + + assertTrue(ruleIds(a).contains("SCENE-011-COMBAT-NO-ENEMY")); + assertEquals(ReadinessStatus.PLAYABLE, a.overallStatus()); + assertEquals(0, a.counts().get("BLOCKING")); + assertEquals(1, a.counts().get("RECOMMENDED")); + } + + @Test + void combatWithFreeTextEnemies_noGap() { + Scene s = Scene.builder().id("scene-1").name("S1").chapterId("chap-1").order(0) + .combatDifficulty("Moyen").enemies("2 gobelins").build(); + tree("arc-1", "chap-1", List.of(s)); + + assertFalse(ruleIds(service.assess(CAMP)).contains("SCENE-011-COMBAT-NO-ENEMY")); + } + + @Test + void combatWithResolvedEnemyId_noGap() { + Scene s = Scene.builder().id("scene-1").name("S1").chapterId("chap-1").order(0) + .combatDifficulty("Moyen").enemyIds(List.of("enemy-1")).build(); + tree("arc-1", "chap-1", List.of(s)); + when(enemyRepository.findByCampaignId(CAMP)).thenReturn(List.of( + Enemy.builder().id("enemy-1").name("Gobelin").campaignId(CAMP).build())); + + Set rules = ruleIds(service.assess(CAMP)); + assertFalse(rules.contains("SCENE-011-COMBAT-NO-ENEMY")); + assertFalse(rules.contains("SCENE-012-ENEMY-REF-BROKEN")); + } + + // --- SCENE-012 : réf d'ennemi cassée --- + + @Test + void brokenEnemyRef_yieldsGap() { + Scene s = Scene.builder().id("scene-1").name("S1").chapterId("chap-1").order(0) + .enemyIds(List.of("ghost-enemy")).build(); + tree("arc-1", "chap-1", List.of(s)); + + assertTrue(ruleIds(service.assess(CAMP)).contains("SCENE-012-ENEMY-REF-BROKEN")); + } + + // --- SCENE-041 / SCENE-042 : pièces --- + + @Test + void roomBranchToGhost_isBroken() { + Room room = Room.builder().id("room-1").name("R1") + .branches(List.of(new RoomBranch("Porte", "ghost-room", null))).build(); + Scene s = Scene.builder().id("scene-1").name("S1").chapterId("chap-1").order(0) + .rooms(List.of(room)).build(); + tree("arc-1", "chap-1", List.of(s)); + + assertTrue(ruleIds(service.assess(CAMP)).contains("SCENE-041-ROOMBRANCH-INVALID")); + } + + @Test + void roomEnemyRefBroken_yieldsGap() { + Room room = Room.builder().id("room-1").name("R1").enemyIds(List.of("ghost-enemy")).build(); + Scene s = Scene.builder().id("scene-1").name("S1").chapterId("chap-1").order(0) + .rooms(List.of(room)).build(); + tree("arc-1", "chap-1", List.of(s)); + + assertTrue(ruleIds(service.assess(CAMP)).contains("SCENE-042-ROOM-ENEMY-BROKEN")); + } + + @Test + void roomWithoutEnemies_isLegitimate_noGap() { + // Un lieu explorable purement descriptif (salle au trésor, énigme…) sans ennemi + // est parfaitement valide : aucune règle ne doit s'en plaindre. + Room room = Room.builder().id("room-1").name("Salle du trésor").enemyIds(List.of()).build(); + Scene s = Scene.builder().id("scene-1").name("Donjon").chapterId("chap-1").order(0) + .rooms(List.of(room)).build(); + tree("arc-1", "chap-1", List.of(s)); + + Set rules = ruleIds(service.assess(CAMP)); + assertFalse(rules.contains("SCENE-042-ROOM-ENEMY-BROKEN")); + assertFalse(rules.contains("SCENE-041-ROOMBRANCH-INVALID")); + } + + // --- QUEST-001 / QUEST-010 / CAMP-010 --- + + @Test + void questWithoutNodes_yieldsGap() { + tree("arc-1", "chap-1", List.of(scene("scene-1", "chap-1"))); + Quest q = Quest.builder().id("q-1").name("Q").campaignId(CAMP).nodes(List.of()).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q)); + + assertTrue(ruleIds(service.assess(CAMP)).contains("QUEST-001-NO-NODES")); + } + + @Test + void questNodeToGhostChapter_isBroken() { + tree("arc-1", "chap-1", List.of(scene("scene-1", "chap-1"))); + Quest q = Quest.builder().id("q-1").name("Q").campaignId(CAMP) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "ghost-chap", 0))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q)); + + Set rules = ruleIds(service.assess(CAMP)); + assertTrue(rules.contains("QUEST-010-NODE-REF-BROKEN")); + assertFalse(rules.contains("QUEST-001-NO-NODES")); + } + + @Test + void questNodeToExistingChapter_isValid() { + tree("arc-1", "chap-1", List.of(scene("scene-1", "chap-1"))); + Quest q = Quest.builder().id("q-1").name("Q").campaignId(CAMP) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q)); + + Set rules = ruleIds(service.assess(CAMP)); + assertFalse(rules.contains("QUEST-010-NODE-REF-BROKEN")); + assertEquals(ReadinessStatus.POLISHED, service.assess(CAMP).overallStatus()); + } + + @Test + void danglingQuestPrerequisite_yieldsGap() { + tree("arc-1", "chap-1", List.of(scene("scene-1", "chap-1"))); + Quest q = Quest.builder().id("q-1").name("Q").campaignId(CAMP) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))) + .prerequisites(List.of(new Prerequisite.QuestCompleted("ghost-quest"))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q)); + + assertTrue(ruleIds(service.assess(CAMP)).contains("CAMP-010-DANGLING-QUEST-PREREQ")); + } + + @Test + void satisfiedQuestPrerequisite_noGap() { + tree("arc-1", "chap-1", List.of(scene("scene-1", "chap-1"))); + Quest q1 = Quest.builder().id("q-1").name("Q1").campaignId(CAMP) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build(); + Quest q2 = Quest.builder().id("q-2").name("Q2").campaignId(CAMP) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))) + .prerequisites(List.of(new Prerequisite.QuestCompleted("q-1"))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q1, q2)); + + assertFalse(ruleIds(service.assess(CAMP)).contains("CAMP-010-DANGLING-QUEST-PREREQ")); + } + + // --- Mode plat : pas d'arc, quête porteuse de contenu --- + + @Test + void flatMode_questCarriesContent_noEmptyCampaignGap() { + // Aucun arc. Une quête avec des nœuds => la campagne n'est pas "vide". + Quest q = Quest.builder().id("q-1").name("Q").campaignId(CAMP) + .nodes(List.of(new QuestNodeRef(NodeType.SCENE, "scene-x", 0))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(q)); + + assertFalse(ruleIds(service.assess(CAMP)).contains("CAMP-001-NO-CONTENT")); + } + + // --- Agrégation : tri + statut --- + + @Test + void aggregation_mixesSeverities_draftAndBlockingFirst() { + // arc-1 vide (BLOQUANT) ; arc-2 -> chap-2 -> scène combat sans ennemi (RECOMMANDÉ). + Scene combat = Scene.builder().id("scene-1").name("S1").chapterId("chap-2").order(0) + .combatDifficulty("Difficile").build(); + when(arcRepository.findByCampaignId(CAMP)).thenReturn(List.of(arc("arc-1"), arc("arc-2"))); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of()); + when(chapterRepository.findByArcId("arc-2")).thenReturn(List.of(chapter("chap-2", "arc-2"))); + when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(combat)); + + CampaignReadinessAssessment a = service.assess(CAMP); + + assertEquals(ReadinessStatus.DRAFT, a.overallStatus()); + assertTrue(a.counts().get("BLOCKING") >= 1); + assertEquals(1, a.counts().get("RECOMMENDED")); + // Tri : le premier gap est de sévérité BLOQUANTE. + assertEquals(ReadinessSeverity.BLOCKING, a.gaps().get(0).severity()); + } + + // --- Contexte de navigation --- + + @Test + void sceneGap_carriesArcAndChapterContext() { + Scene s = Scene.builder().id("scene-1").name(" ").chapterId("chap-1").order(0).build(); + tree("arc-1", "chap-1", List.of(s)); + + ReadinessGap gap = service.assess(CAMP).gaps().stream() + .filter(g -> g.ruleId().equals("SCENE-001-NO-NAME")).findFirst().orElseThrow(); + + assertEquals("arc-1", gap.arcId()); + assertEquals("chap-1", gap.chapterId()); + assertEquals("scene-1", gap.entityId()); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsServiceTest.java index ec05d6d..d2faccc 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsServiceTest.java @@ -1,10 +1,8 @@ package com.loremind.application.campaigncontext; -import com.loremind.domain.campaigncontext.Arc; -import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Prerequisite; -import com.loremind.domain.campaigncontext.ports.ArcRepository; -import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.ports.QuestRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -18,64 +16,51 @@ import static org.mockito.Mockito.*; /** * Test unitaire pour CampaignReferencedFlagsService. - * Mocks des ports ArcRepository / ChapterRepository. - * Vérifie l'énumération dédupliquée + triée des FlagSet référencés par les chapitres. + * Mock du port QuestRepository. + * Vérifie l'énumération dédupliquée + triée des FlagSet référencés par les quêtes. */ @ExtendWith(MockitoExtension.class) public class CampaignReferencedFlagsServiceTest { @Mock - private ArcRepository arcRepository; - @Mock - private ChapterRepository chapterRepository; + private QuestRepository questRepository; @InjectMocks private CampaignReferencedFlagsService service; - private static Chapter chapterWithPrereqs(String id, String arcId, Prerequisite... prereqs) { - return Chapter.builder() + private static Quest questWithPrereqs(String id, Prerequisite... prereqs) { + return Quest.builder() .id(id) - .arcId(arcId) - .name("C-" + id) + .campaignId("camp-1") + .name("Q-" + id) .prerequisites(List.of(prereqs)) .build(); } @Test void testListForCampaign_DeduplicatedAndSorted() { - // Arrange : deux arcs, plusieurs chapitres, flags dont certains en doublon. - Arc arc1 = Arc.builder().id("arc-1").campaignId("camp-1").build(); - Arc arc2 = Arc.builder().id("arc-2").campaignId("camp-1").build(); - when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc1, arc2)); - - Chapter c1 = chapterWithPrereqs("c1", "arc-1", + Quest q1 = questWithPrereqs("q1", new Prerequisite.FlagSet("zeta"), new Prerequisite.FlagSet("alpha")); - Chapter c2 = chapterWithPrereqs("c2", "arc-2", + Quest q2 = questWithPrereqs("q2", new Prerequisite.FlagSet("alpha"), // doublon -> dédupliqué new Prerequisite.FlagSet("beta")); - when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1)); - when(chapterRepository.findByArcId("arc-2")).thenReturn(List.of(c2)); + when(questRepository.findByCampaignId("camp-1")).thenReturn(List.of(q1, q2)); - // Act List result = service.listForCampaign("camp-1"); - // Assert : tri alphabétique + déduplication. assertEquals(List.of("alpha", "beta", "zeta"), result); } @Test void testListForCampaign_IgnoresNonFlagSetAndBlankNames() { - Arc arc = Arc.builder().id("arc-1").campaignId("camp-1").build(); - when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc)); - - Chapter chapter = chapterWithPrereqs("c1", "arc-1", + Quest quest = questWithPrereqs("q1", new Prerequisite.QuestCompleted("quest-x"), // pas un FlagSet -> ignoré new Prerequisite.SessionReached(3), // pas un FlagSet -> ignoré new Prerequisite.FlagSet(""), // blanc -> ignoré new Prerequisite.FlagSet(" "), // blanc -> ignoré new Prerequisite.FlagSet("real")); - when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter)); + when(questRepository.findByCampaignId("camp-1")).thenReturn(List.of(quest)); List result = service.listForCampaign("camp-1"); @@ -84,11 +69,8 @@ public class CampaignReferencedFlagsServiceTest { @Test void testListForCampaign_NullPrerequisitesSkipped() { - Arc arc = Arc.builder().id("arc-1").campaignId("camp-1").build(); - when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc)); - - Chapter chapter = Chapter.builder().id("c1").arcId("arc-1").name("C").prerequisites(null).build(); - when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter)); + Quest quest = Quest.builder().id("q1").campaignId("camp-1").name("Q").prerequisites(null).build(); + when(questRepository.findByCampaignId("camp-1")).thenReturn(List.of(quest)); List result = service.listForCampaign("camp-1"); @@ -96,12 +78,11 @@ public class CampaignReferencedFlagsServiceTest { } @Test - void testListForCampaign_NoArcs() { - when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); + void testListForCampaign_NoQuests() { + when(questRepository.findByCampaignId("camp-1")).thenReturn(List.of()); List result = service.listForCampaign("camp-1"); assertTrue(result.isEmpty()); - verify(chapterRepository, never()).findByArcId(anyString()); } } diff --git a/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java index b535489..b4d7db0 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java @@ -1,6 +1,7 @@ package com.loremind.application.campaigncontext; import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; @@ -48,6 +49,32 @@ public class ChapterServiceTest { .build(); } + @Test + void patchChapter_appliesOnlyProvidedFields_leavesOthersIntact() { + Chapter existing = Chapter.builder() + .id("chapter-1").name("Nom").arcId("arc-1") + .description("desc orig").gmNotes("secret").playerObjectives("obj orig").build(); + when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(existing)); + when(chapterRepository.save(any(Chapter.class))).thenAnswer(inv -> inv.getArgument(0)); + + Chapter result = chapterService.patchChapter("chapter-1", List.of( + new FieldProposal("playerObjectives", "obj orig", "Sauver le village"), + new FieldProposal("id", "x", "HACK"))); + + assertEquals("Sauver le village", result.getPlayerObjectives()); + assertEquals("desc orig", result.getDescription()); // intact + assertEquals("secret", result.getGmNotes()); // intact + assertEquals("chapter-1", result.getId()); // clé hors whitelist ignorée + verify(chapterRepository, times(1)).save(any(Chapter.class)); + } + + @Test + void patchChapter_notFound_throws() { + when(chapterRepository.findById("nope")).thenReturn(Optional.empty()); + assertThrows(IllegalArgumentException.class, () -> chapterService.patchChapter("nope", List.of())); + verify(chapterRepository, never()).save(any()); + } + @Test void testCreateChapter() { // Arrange diff --git a/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java new file mode 100644 index 0000000..b27c346 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java @@ -0,0 +1,256 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import com.loremind.domain.playcontext.ports.QuestProgressionRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Test unitaire pour QuestService — cycle de vie d'une quête d'arc HUB et de son + * CONTENEUR de scènes (chapitre jumeau) : provisioning à la création, suivi du + * renommage, nettoyage à la suppression. + */ +@ExtendWith(MockitoExtension.class) +class QuestServiceTest { + + @Mock private QuestRepository questRepository; + @Mock private QuestProgressionRepository progressionRepository; + @Mock private ChapterRepository chapterRepository; + @Mock private SceneRepository sceneRepository; + @Mock private ArcRepository arcRepository; + + @InjectMocks private QuestService service; + + // ─────────────── createQuest : provisioning du conteneur ─────────────── + + @Test + void createQuest_attachedToHubArc_provisionsContainerChapter() { + when(chapterRepository.findByArcId("arc-h")).thenReturn(List.of( + Chapter.builder().id("chap-1").arcId("arc-h").order(2).build())); // max order = 2 + when(chapterRepository.save(any(Chapter.class))).thenAnswer(inv -> { + Chapter ch = inv.getArgument(0); + ch.setId("chap-new"); + return ch; + }); + when(questRepository.save(any(Quest.class))).thenAnswer(inv -> inv.getArgument(0)); + + Quest created = service.createQuest(Quest.builder() + .campaignId("camp").arcId("arc-h").name("Enquête").order(0).build()); + + // Conteneur créé dans l'arc, même nom, ordre à la suite, et référencé en nœud CHAPTER. + verify(chapterRepository).save(any(Chapter.class)); + assertEquals(1, created.getNodes().size()); + QuestNodeRef node = created.getNodes().get(0); + assertEquals(NodeType.CHAPTER, node.nodeType()); + assertEquals("chap-new", node.nodeId()); + } + + @Test + void createQuest_free_provisionsContainerInNewSystemArc() { + // Aucun arc SYSTEM : il est créé au premier besoin, puis le conteneur dedans. + when(arcRepository.findByCampaignId("camp")).thenReturn(List.of( + Arc.builder().id("arc-lin").campaignId("camp").name("Arc").type(ArcType.LINEAR).build())); + when(arcRepository.save(any(Arc.class))).thenAnswer(inv -> { + Arc a = inv.getArgument(0); + a.setId("arc-sys"); + return a; + }); + when(chapterRepository.findByArcId("arc-sys")).thenReturn(List.of()); + when(chapterRepository.save(any(Chapter.class))).thenAnswer(inv -> { + Chapter ch = inv.getArgument(0); + ch.setId("chap-free"); + return ch; + }); + when(questRepository.save(any(Quest.class))).thenAnswer(inv -> inv.getArgument(0)); + + Quest created = service.createQuest(Quest.builder() + .campaignId("camp").name("Libre").order(0).build()); + + verify(arcRepository).save(any(Arc.class)); // arc SYSTEM créé + assertEquals(1, created.getNodes().size()); + assertEquals("chap-free", created.getNodes().get(0).nodeId()); + } + + @Test + void createQuest_free_reusesExistingSystemArc() { + when(arcRepository.findByCampaignId("camp")).thenReturn(List.of( + Arc.builder().id("arc-sys").campaignId("camp").name("Quêtes libres").type(ArcType.SYSTEM).build())); + when(chapterRepository.findByArcId("arc-sys")).thenReturn(List.of()); + when(chapterRepository.save(any(Chapter.class))).thenAnswer(inv -> { + Chapter ch = inv.getArgument(0); + ch.setId("chap-free"); + return ch; + }); + when(questRepository.save(any(Quest.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.createQuest(Quest.builder().campaignId("camp").name("Libre").order(0).build()); + + verify(arcRepository, never()).save(any()); // arc SYSTEM existant réutilisé + verify(chapterRepository).save(any(Chapter.class)); + } + + @Test + void createQuest_attachedWithExistingNodes_noProvisioning() { + when(questRepository.save(any(Quest.class))).thenAnswer(inv -> inv.getArgument(0)); + + Quest created = service.createQuest(Quest.builder() + .campaignId("camp").arcId("arc-h").name("Q").order(0) + .nodes(List.of(new QuestNodeRef(NodeType.SCENE, "scene-1", 0))).build()); + + verify(chapterRepository, never()).save(any()); + assertEquals(1, created.getNodes().size()); + } + + // ─────────────── updateQuest : le conteneur suit le renommage ─────────────── + + @Test + void updateQuest_rename_renamesMatchingContainer() { + Quest existing = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Ancien nom") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(existing)); + when(questRepository.save(any(Quest.class))).thenAnswer(inv -> inv.getArgument(0)); + Chapter container = Chapter.builder().id("chap-1").arcId("arc-h").name("Ancien nom").build(); + when(chapterRepository.findById("chap-1")).thenReturn(Optional.of(container)); + when(chapterRepository.save(any(Chapter.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.updateQuest("q-1", Quest.builder() + .campaignId("camp").arcId("arc-h").name("Nouveau nom") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build()); + + assertEquals("Nouveau nom", container.getName()); + verify(chapterRepository).save(container); + } + + @Test + void updateQuest_legacyQuestWithoutNodes_selfHealsWithContainer() { + // Quête libre créée AVANT le provisioning systématique : réparée à la sauvegarde. + Quest existing = Quest.builder().id("q-1").campaignId("camp").name("Legacy").build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(existing)); + when(questRepository.save(any(Quest.class))).thenAnswer(inv -> inv.getArgument(0)); + when(arcRepository.findByCampaignId("camp")).thenReturn(List.of( + Arc.builder().id("arc-sys").campaignId("camp").name("Quêtes libres").type(ArcType.SYSTEM).build())); + when(chapterRepository.findByArcId("arc-sys")).thenReturn(List.of()); + when(chapterRepository.save(any(Chapter.class))).thenAnswer(inv -> { + Chapter ch = inv.getArgument(0); + ch.setId("chap-heal"); + return ch; + }); + + Quest saved = service.updateQuest("q-1", Quest.builder() + .campaignId("camp").name("Legacy").build()); + + assertEquals(1, saved.getNodes().size()); + assertEquals("chap-heal", saved.getNodes().get(0).nodeId()); + } + + // ─────────────── deleteQuest : nettoyage du conteneur ─────────────── + + @Test + void deleteQuest_removesEmptyUnreferencedContainer() { + Quest quest = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Q") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of()); // plus aucune quête + when(chapterRepository.findById("chap-1")) + .thenReturn(Optional.of(Chapter.builder().id("chap-1").arcId("arc-h").name("Q").build())); + when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of()); // conteneur vide + + service.deleteQuest("q-1"); + + verify(chapterRepository).deleteById("chap-1"); // pas de « chapitre vide » fantôme + verify(progressionRepository).deleteByQuestId("q-1"); + verify(questRepository).deleteById("q-1"); + } + + @Test + void deleteQuest_keepsContainerWithScenes() { + Quest quest = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Q") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of()); + when(chapterRepository.findById("chap-1")) + .thenReturn(Optional.of(Chapter.builder().id("chap-1").arcId("arc-h").name("Q").build())); + when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of( + Scene.builder().id("s-1").chapterId("chap-1").name("S").build())); + + service.deleteQuest("q-1"); + + // Contenu préservé : le conteneur redevient un chapitre visible de l'arc. + verify(chapterRepository, never()).deleteById(anyString()); + } + + @Test + void deleteQuest_freeQuest_removesEmptyContainerFromSystemArc() { + Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre") // arcId null = libre + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of()); + when(chapterRepository.findById("chap-free")) + .thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build())); + when(arcRepository.findById("arc-sys")) + .thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build())); + when(sceneRepository.findByChapterId("chap-free")).thenReturn(List.of()); + + service.deleteQuest("q-1"); + + verify(chapterRepository).deleteById("chap-free"); + } + + @Test + void deleteQuest_transverseLink_neverDeletesLinkedRealChapter() { + // Quête libre LIANT un chapitre réel d'un arc LINÉAIRE (même vide) : intouchable. + Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Transverse") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-real", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of()); + when(chapterRepository.findById("chap-real")) + .thenReturn(Optional.of(Chapter.builder().id("chap-real").arcId("arc-lin").name("Réel").build())); + when(arcRepository.findById("arc-lin")) + .thenReturn(Optional.of(Arc.builder().id("arc-lin").type(ArcType.LINEAR).build())); + when(sceneRepository.findByChapterId("chap-real")).thenReturn(List.of()); + + service.deleteQuest("q-1"); + + verify(chapterRepository, never()).deleteById(anyString()); + } + + @Test + void deleteQuest_keepsContainerReferencedByAnotherQuest() { + Quest quest = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Q") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build(); + Quest other = Quest.builder().id("q-2").campaignId("camp").arcId("arc-h").name("Autre") + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build(); + when(questRepository.findById("q-1")).thenReturn(Optional.of(quest)); + when(questRepository.findByCampaignId("camp")).thenReturn(List.of(other)); + when(chapterRepository.findById("chap-1")) + .thenReturn(Optional.of(Chapter.builder().id("chap-1").arcId("arc-h").name("Q").build())); + when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of()); + + service.deleteQuest("q-1"); + + verify(chapterRepository, never()).deleteById(anyString()); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/ChapterStatusEnricherTest.java b/core/src/test/java/com/loremind/application/campaigncontext/QuestStatusEnricherTest.java similarity index 56% rename from core/src/test/java/com/loremind/application/campaigncontext/ChapterStatusEnricherTest.java rename to core/src/test/java/com/loremind/application/campaigncontext/QuestStatusEnricherTest.java index 4fcc2f0..023df39 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/ChapterStatusEnricherTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/QuestStatusEnricherTest.java @@ -1,8 +1,8 @@ package com.loremind.application.campaigncontext; -import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.QuestStatus; import com.loremind.domain.playcontext.Playthrough; import com.loremind.domain.playcontext.QuestProgression; @@ -11,7 +11,7 @@ import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; import com.loremind.domain.playcontext.ports.PlaythroughRepository; import com.loremind.domain.playcontext.ports.QuestProgressionRepository; import com.loremind.domain.playcontext.ports.SessionRepository; -import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -27,34 +27,29 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; /** - * Test unitaire pour ChapterStatusEnricher. + * Test unitaire pour QuestStatusEnricher. * Mocks des ports Play Context (playthrough, progression, flags, sessions). * Vérifie la construction du snapshot d'évaluation + le calcul du statut effectif. */ @ExtendWith(MockitoExtension.class) -public class ChapterStatusEnricherTest { +public class QuestStatusEnricherTest { - @Mock - private PlaythroughRepository playthroughRepository; - @Mock - private QuestProgressionRepository progressionRepository; - @Mock - private PlaythroughFlagRepository flagRepository; - @Mock - private SessionRepository sessionRepository; + @Mock private PlaythroughRepository playthroughRepository; + @Mock private QuestProgressionRepository progressionRepository; + @Mock private PlaythroughFlagRepository flagRepository; + @Mock private SessionRepository sessionRepository; - @InjectMocks - private ChapterStatusEnricher enricher; + @InjectMocks private QuestStatusEnricher enricher; // --- buildSnapshot --- @Test void testBuildSnapshot_NullPlaythroughId_ReturnsEmptyContext() { // Aucune interaction avec les repos de données : court-circuit. - ChapterStatusEnricher.PlaythroughEvalSnapshot snap = enricher.buildSnapshot(null); + QuestStatusEnricher.PlaythroughEvalSnapshot snap = enricher.buildSnapshot(null); assertNotNull(snap); - assertTrue(snap.progressionByChapterId().isEmpty()); + assertTrue(snap.progressionByQuestId().isEmpty()); assertTrue(snap.ctx().completedQuestIds().isEmpty()); assertEquals(0, snap.ctx().currentSessionCount()); assertTrue(snap.ctx().campaignFlags().isEmpty()); @@ -66,9 +61,9 @@ public class ChapterStatusEnricherTest { void testBuildSnapshot_UnknownPlaythrough_ReturnsEmptyContext() { when(playthroughRepository.findById("pt-x")).thenReturn(Optional.empty()); - ChapterStatusEnricher.PlaythroughEvalSnapshot snap = enricher.buildSnapshot("pt-x"); + QuestStatusEnricher.PlaythroughEvalSnapshot snap = enricher.buildSnapshot("pt-x"); - assertTrue(snap.progressionByChapterId().isEmpty()); + assertTrue(snap.progressionByQuestId().isEmpty()); assertTrue(snap.ctx().completedQuestIds().isEmpty()); verify(flagRepository, never()).findByPlaythroughId(anyString()); } @@ -78,57 +73,39 @@ public class ChapterStatusEnricherTest { when(playthroughRepository.findById("pt-1")) .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of("door_open", true)); - when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")) - .thenReturn(Set.of("chap-done")); + when(progressionRepository.findCompletedQuestIdsByPlaythroughId("pt-1")) + .thenReturn(Set.of("quest-done")); when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( Session.builder().id("s1").build(), Session.builder().id("s2").build())); when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( - QuestProgression.builder().chapterId("chap-1").status(ProgressionStatus.IN_PROGRESS).build(), - QuestProgression.builder().chapterId("chap-2").status(ProgressionStatus.COMPLETED).build())); + QuestProgression.builder().questId("quest-1").status(ProgressionStatus.IN_PROGRESS).build(), + QuestProgression.builder().questId("quest-2").status(ProgressionStatus.COMPLETED).build())); - ChapterStatusEnricher.PlaythroughEvalSnapshot snap = enricher.buildSnapshot("pt-1"); + QuestStatusEnricher.PlaythroughEvalSnapshot snap = enricher.buildSnapshot("pt-1"); - assertEquals(Set.of("chap-done"), snap.ctx().completedQuestIds()); + assertEquals(Set.of("quest-done"), snap.ctx().completedQuestIds()); assertEquals(2, snap.ctx().currentSessionCount()); assertEquals(Map.of("door_open", true), snap.ctx().campaignFlags()); - assertEquals(ProgressionStatus.IN_PROGRESS, snap.progressionByChapterId().get("chap-1")); - assertEquals(ProgressionStatus.COMPLETED, snap.progressionByChapterId().get("chap-2")); + assertEquals(ProgressionStatus.IN_PROGRESS, snap.progressionByQuestId().get("quest-1")); + assertEquals(ProgressionStatus.COMPLETED, snap.progressionByQuestId().get("quest-2")); } // --- computeFor --- @Test void testComputeFor_NotStartedWithNoPrereqs_Available() { - when(playthroughRepository.findById("pt-1")) - .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); - when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of()); - when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); - when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); - when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); - - Chapter chapter = Chapter.builder().id("chap-1").name("C").prerequisites(List.of()).build(); - - QuestStatus status = enricher.computeFor(chapter, "pt-1"); - - assertEquals(QuestStatus.AVAILABLE, status); + stubEmptyPlaythrough(); + Quest quest = Quest.builder().id("quest-1").name("Q").prerequisites(List.of()).build(); + assertEquals(QuestStatus.AVAILABLE, enricher.computeFor(quest, "pt-1")); } @Test void testComputeFor_NotStartedWithUnmetPrereq_Locked() { - when(playthroughRepository.findById("pt-1")) - .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); - when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of()); - when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); - when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); - when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); - - Chapter chapter = Chapter.builder().id("chap-1").name("C") + stubEmptyPlaythrough(); + Quest quest = Quest.builder().id("quest-1").name("Q") .prerequisites(List.of(new Prerequisite.QuestCompleted("other"))).build(); - - QuestStatus status = enricher.computeFor(chapter, "pt-1"); - - assertEquals(QuestStatus.LOCKED, status); + assertEquals(QuestStatus.LOCKED, enricher.computeFor(quest, "pt-1")); } @Test @@ -136,26 +113,20 @@ public class ChapterStatusEnricherTest { when(playthroughRepository.findById("pt-1")) .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of()); - when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); + when(progressionRepository.findCompletedQuestIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( - QuestProgression.builder().chapterId("chap-1").status(ProgressionStatus.COMPLETED).build())); + QuestProgression.builder().questId("quest-1").status(ProgressionStatus.COMPLETED).build())); - Chapter chapter = Chapter.builder().id("chap-1").name("C").prerequisites(List.of()).build(); - - QuestStatus status = enricher.computeFor(chapter, "pt-1"); - - assertEquals(QuestStatus.COMPLETED, status); + Quest quest = Quest.builder().id("quest-1").name("Q").prerequisites(List.of()).build(); + assertEquals(QuestStatus.COMPLETED, enricher.computeFor(quest, "pt-1")); } @Test void testComputeFor_NoPlaythrough_FallsBackToAvailableWhenNoPrereqs() { - // playthroughId null -> snapshot vide -> NOT_STARTED par défaut. - Chapter chapter = Chapter.builder().id("chap-1").name("C").prerequisites(List.of()).build(); - - QuestStatus status = enricher.computeFor(chapter, null); - - assertEquals(QuestStatus.AVAILABLE, status); + // playthroughId null -> snapshot vide -> NOT_STARTED par défaut -> AVAILABLE sans prereq. + Quest quest = Quest.builder().id("quest-1").name("Q").prerequisites(List.of()).build(); + assertEquals(QuestStatus.AVAILABLE, enricher.computeFor(quest, null)); } // --- enrich --- @@ -164,8 +135,6 @@ public class ChapterStatusEnricherTest { void testEnrich_NullOrEmptyDtos_NoSnapshotBuilt() { enricher.enrich(null, List.of(), "pt-1"); enricher.enrich(List.of(), List.of(), "pt-1"); - - // Aucun snapshot construit -> pas d'accès au repo playthrough. verify(playthroughRepository, never()).findById(anyString()); } @@ -174,31 +143,40 @@ public class ChapterStatusEnricherTest { when(playthroughRepository.findById("pt-1")) .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of()); - when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); + when(progressionRepository.findCompletedQuestIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( - QuestProgression.builder().chapterId("chap-1").status(ProgressionStatus.IN_PROGRESS).build())); + QuestProgression.builder().questId("quest-1").status(ProgressionStatus.IN_PROGRESS).build())); - Chapter c1 = Chapter.builder().id("chap-1").name("C1").prerequisites(List.of()).build(); - Chapter c2 = Chapter.builder().id("chap-2").name("C2").prerequisites(List.of()).build(); + Quest q1 = Quest.builder().id("quest-1").name("Q1").prerequisites(List.of()).build(); + Quest q2 = Quest.builder().id("quest-2").name("Q2").prerequisites(List.of()).build(); - ChapterDTO d1 = new ChapterDTO(); - d1.setId("chap-1"); - ChapterDTO d2 = new ChapterDTO(); - d2.setId("chap-2"); - ChapterDTO orphan = new ChapterDTO(); - orphan.setId("chap-unknown"); // pas dans domain -> ignoré + QuestDTO d1 = new QuestDTO(); + d1.setId("quest-1"); + QuestDTO d2 = new QuestDTO(); + d2.setId("quest-2"); + QuestDTO orphan = new QuestDTO(); + orphan.setId("quest-unknown"); // pas dans domain -> ignoré - enricher.enrich(List.of(d1, d2, orphan), List.of(c1, c2), "pt-1"); + enricher.enrich(List.of(d1, d2, orphan), List.of(q1, q2), "pt-1"); - // chap-1 : progression IN_PROGRESS -> effectif IN_PROGRESS. + // quest-1 : progression IN_PROGRESS -> effectif IN_PROGRESS. assertEquals("IN_PROGRESS", d1.getProgressionStatus()); assertEquals("IN_PROGRESS", d1.getEffectiveStatus()); - // chap-2 : pas de progression -> NOT_STARTED + sans prereq -> AVAILABLE. + // quest-2 : pas de progression -> NOT_STARTED + sans prereq -> AVAILABLE. assertEquals("NOT_STARTED", d2.getProgressionStatus()); assertEquals("AVAILABLE", d2.getEffectiveStatus()); // orphelin : non touché. assertNull(orphan.getProgressionStatus()); assertNull(orphan.getEffectiveStatus()); } + + private void stubEmptyPlaythrough() { + when(playthroughRepository.findById("pt-1")) + .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); + when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of()); + when(progressionRepository.findCompletedQuestIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); + when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); + when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); + } } diff --git a/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java index 551a918..751ab00 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java @@ -1,7 +1,9 @@ package com.loremind.application.campaigncontext; +import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.domain.campaigncontext.SceneDraft; import com.loremind.domain.campaigncontext.ports.SceneRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -303,6 +305,103 @@ public class SceneServiceTest { verify(sceneRepository, times(1)).save(any(Scene.class)); } + // ─────────────── patchScene (Pilier A — co-création) ─────────────── + + @Test + void patchScene_appliesOnlyProvidedFields_leavesOthersIntact() { + Scene existing = Scene.builder() + .id("scene-1").name("Nom").chapterId("chapter-1").order(1) + .description("desc orig").location("Taverne").atmosphere("tendue") + .playerNarration("narration orig").gmSecretNotes("secret orig") + .build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(existing)); + when(sceneRepository.save(any(Scene.class))).thenAnswer(inv -> inv.getArgument(0)); + + List fields = List.of( + new FieldProposal("atmosphere", "tendue", "oppressante, brumeuse"), + new FieldProposal("playerNarration", "narration orig", "Une nouvelle narration.") + ); + Scene result = sceneService.patchScene("scene-1", fields); + + // Champs patchés + assertEquals("oppressante, brumeuse", result.getAtmosphere()); + assertEquals("Une nouvelle narration.", result.getPlayerNarration()); + // Champs NON proposés : strictement intacts (garde-fou anti-écrasement) + assertEquals("desc orig", result.getDescription()); + assertEquals("Taverne", result.getLocation()); + assertEquals("secret orig", result.getGmSecretNotes()); + assertEquals("Nom", result.getName()); + assertEquals("chapter-1", result.getChapterId()); + verify(sceneRepository, times(1)).save(any(Scene.class)); + } + + @Test + void patchScene_ignoresUnknownAndNonWhitelistedKeys() { + Scene existing = Scene.builder() + .id("scene-1").name("Nom").chapterId("chapter-1").description("desc").build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(existing)); + when(sceneRepository.save(any(Scene.class))).thenAnswer(inv -> inv.getArgument(0)); + + Scene result = sceneService.patchScene("scene-1", List.of( + new FieldProposal("id", "x", "HACK"), // hors whitelist + new FieldProposal("name", "Nom", "AUTRE"), // name n'est pas patchable + new FieldProposal("unknownKey", "", "y") + )); + + assertEquals("Nom", result.getName()); // inchangé + assertEquals("scene-1", result.getId()); // inchangé + assertEquals("desc", result.getDescription()); + } + + @Test + void patchScene_notFound_throws() { + when(sceneRepository.findById("nope")).thenReturn(Optional.empty()); + assertThrows(IllegalArgumentException.class, () -> sceneService.patchScene("nope", List.of())); + verify(sceneRepository, never()).save(any()); + } + + @Test + void patchScene_nullFields_savesUnchanged() { + Scene existing = Scene.builder().id("scene-1").name("Nom").description("desc").build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(existing)); + when(sceneRepository.save(any(Scene.class))).thenAnswer(inv -> inv.getArgument(0)); + + Scene result = sceneService.patchScene("scene-1", null); + + assertEquals("desc", result.getDescription()); + verify(sceneRepository, times(1)).save(any(Scene.class)); + } + + // ─────────────── createDraftScenes (Pilier A — capacité « create ») ─────────────── + + @Test + void createDraftScenes_createsInOrder_skipsBlankNames() { + when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of( + Scene.builder().id("s-0").chapterId("chapter-1").order(2).build())); // max order = 2 + when(sceneRepository.save(any(Scene.class))).thenAnswer(inv -> inv.getArgument(0)); + + List created = sceneService.createDraftScenes("chapter-1", List.of( + new SceneDraft("Embuscade", "desc", "narration"), + new SceneDraft(" ", "x", "y"), // titre vide → ignoré + new SceneDraft("Poursuite", null, null))); + + assertEquals(2, created.size()); + assertEquals("Embuscade", created.get(0).getName()); + assertEquals("chapter-1", created.get(0).getChapterId()); + assertEquals(3, created.get(0).getOrder()); // append après max(2)+1 + assertEquals("narration", created.get(0).getPlayerNarration()); + assertEquals("Poursuite", created.get(1).getName()); + assertEquals(4, created.get(1).getOrder()); + verify(sceneRepository, times(2)).save(any(Scene.class)); + } + + @Test + void createDraftScenes_emptyOrNull_noCreation() { + assertTrue(sceneService.createDraftScenes("chapter-1", null).isEmpty()); + assertTrue(sceneService.createDraftScenes("chapter-1", List.of()).isEmpty()); + verify(sceneRepository, never()).save(any()); + } + @Test void testDeleteScene() { // Arrange @@ -315,6 +414,28 @@ public class SceneServiceTest { verify(sceneRepository, times(1)).deleteById("scene-1"); } + @Test + void deleteScene_cleansSiblingBranchesPointingToIt() { + // scene-2 et scene-3 pointent vers scene-1 ; scene-3 a aussi une branche saine. + Scene s1 = Scene.builder().id("scene-1").name("Cible").chapterId("chapter-1").build(); + Scene s2 = Scene.builder().id("scene-2").name("S2").chapterId("chapter-1") + .branches(List.of(SceneBranch.of("vers cible", "scene-1"))).build(); + Scene s3 = Scene.builder().id("scene-3").name("S3").chapterId("chapter-1") + .branches(List.of(SceneBranch.of("vers cible", "scene-1"), SceneBranch.of("ok", "scene-2"))).build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(s1)); + when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2, s3)); + when(sceneRepository.save(any(Scene.class))).thenAnswer(inv -> inv.getArgument(0)); + + sceneService.deleteScene("scene-1"); + + // Les branches mortes sont retirées, la branche saine survit, puis la scène est supprimée. + assertTrue(s2.getBranches().isEmpty()); + assertEquals(1, s3.getBranches().size()); + assertEquals("scene-2", s3.getBranches().get(0).targetSceneId()); + verify(sceneRepository, times(2)).save(any(Scene.class)); // s2 + s3 (pas s1) + verify(sceneRepository, times(1)).deleteById("scene-1"); + } + @Test void testSceneExists_True() { // Arrange diff --git a/core/src/test/java/com/loremind/application/playcontext/ClockServiceTest.java b/core/src/test/java/com/loremind/application/playcontext/ClockServiceTest.java new file mode 100644 index 0000000..ea673fc --- /dev/null +++ b/core/src/test/java/com/loremind/application/playcontext/ClockServiceTest.java @@ -0,0 +1,128 @@ +package com.loremind.application.playcontext; + +import com.loremind.domain.playcontext.Clock; +import com.loremind.domain.playcontext.ClockTrigger; +import com.loremind.domain.playcontext.ports.ClockRepository; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire de ClockService : bornage de advance/regress, clamp des segments, validations. + */ +@ExtendWith(MockitoExtension.class) +class ClockServiceTest { + + @Mock private ClockRepository clockRepository; + @Mock private PlaythroughRepository playthroughRepository; + @InjectMocks private ClockService service; + + @Test + void advance_incrementsByOne() { + stubFind("1", Clock.builder().id("1").segments(6).filled(2).build()); + assertEquals(3, service.advance("1").getFilled()); + } + + @Test + void advance_doesNotExceedSegments() { + stubFind("1", Clock.builder().id("1").segments(4).filled(4).build()); + assertEquals(4, service.advance("1").getFilled()); + } + + @Test + void regress_doesNotGoBelowZero() { + stubFind("1", Clock.builder().id("1").segments(4).filled(0).build()); + assertEquals(0, service.regress("1").getFilled()); + } + + @Test + void create_clampsSegmentsAndStartsEmpty() { + when(playthroughRepository.existsById("pt")).thenReturn(true); + when(clockRepository.findByPlaythroughId("pt")).thenReturn(List.of()); + when(clockRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + Clock out = service.create("pt", " Famine ", "Le village meurt", 0, ClockTrigger.NONE, null, null); // 0 -> clamp à 1 + assertEquals(1, out.getSegments()); + assertEquals(0, out.getFilled()); + assertEquals("Famine", out.getName()); // trim + assertEquals(0, out.getOrder()); + } + + @Test + void create_unknownPlaythrough_throws() { + when(playthroughRepository.existsById("x")).thenReturn(false); + assertThrows(IllegalArgumentException.class, () -> service.create("x", "C", null, 4, ClockTrigger.NONE, null, null)); + } + + @Test + void create_blankName_throws() { + when(playthroughRepository.existsById("pt")).thenReturn(true); + assertThrows(IllegalArgumentException.class, () -> service.create("pt", " ", null, 4, ClockTrigger.NONE, null, null)); + } + + @Test + void update_reclampsFilledWhenShrinking() { + stubFind("1", Clock.builder().id("1").segments(8).filled(7).name("X").build()); + Clock out = service.update("1", "X", null, 4, ClockTrigger.NONE, null, null); // réduit à 4 -> filled re-borné à 4 + assertEquals(4, out.getSegments()); + assertEquals(4, out.getFilled()); + } + + @Test + void advance_missing_throws() { + when(clockRepository.findById("nope")).thenReturn(Optional.empty()); + assertThrows(IllegalArgumentException.class, () -> service.advance("nope")); + } + + // --- Avancement automatique (co-MJ) --- + + @Test + void onFlagRaised_advancesMatchingClock() { + Clock c = Clock.builder().id("1").playthroughId("pt").segments(4).filled(1) + .triggerType(ClockTrigger.FLAG_SET).triggerRef("porte").build(); + when(clockRepository.findByPlaythroughId("pt")).thenReturn(List.of(c)); + when(clockRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + service.onFlagRaised("pt", "porte"); + assertEquals(2, c.getFilled()); + } + + @Test + void onFlagRaised_ignoresOtherFlagAndOtherTriggerType() { + Clock other = Clock.builder().id("1").playthroughId("pt").segments(4).filled(0) + .triggerType(ClockTrigger.FLAG_SET).triggerRef("autre").build(); + Clock session = Clock.builder().id("2").playthroughId("pt").segments(4).filled(0) + .triggerType(ClockTrigger.SESSION_ENDED).build(); + when(clockRepository.findByPlaythroughId("pt")).thenReturn(List.of(other, session)); + service.onFlagRaised("pt", "porte"); + assertEquals(0, other.getFilled()); + assertEquals(0, session.getFilled()); + verify(clockRepository, never()).save(any()); + } + + @Test + void onSessionEnded_advancesSessionClocks_notThoseAlreadyFull() { + Clock s = Clock.builder().id("1").playthroughId("pt").segments(6).filled(5) + .triggerType(ClockTrigger.SESSION_ENDED).build(); + Clock full = Clock.builder().id("2").playthroughId("pt").segments(2).filled(2) + .triggerType(ClockTrigger.SESSION_ENDED).build(); + when(clockRepository.findByPlaythroughId("pt")).thenReturn(List.of(s, full)); + when(clockRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + service.onSessionEnded("pt"); + assertEquals(6, s.getFilled()); + assertEquals(2, full.getFilled()); + } + + private void stubFind(String id, Clock clock) { + when(clockRepository.findById(id)).thenReturn(Optional.of(clock)); + when(clockRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + } +} diff --git a/core/src/test/java/com/loremind/application/playcontext/SessionPrepServiceTest.java b/core/src/test/java/com/loremind/application/playcontext/SessionPrepServiceTest.java new file mode 100644 index 0000000..b0e6a99 --- /dev/null +++ b/core/src/test/java/com/loremind/application/playcontext/SessionPrepServiceTest.java @@ -0,0 +1,220 @@ +package com.loremind.application.playcontext; + +import com.loremind.application.campaigncontext.CampaignReadinessAssessment; +import com.loremind.application.campaigncontext.CampaignReadinessService; +import com.loremind.application.campaigncontext.QuestStatusEnricher; +import com.loremind.application.campaigncontext.ReadinessGap; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.domain.campaigncontext.QuestStatus; +import com.loremind.domain.campaigncontext.ReadinessEntityType; +import com.loremind.domain.campaigncontext.ReadinessSeverity; +import com.loremind.domain.campaigncontext.ReadinessStatus; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import com.loremind.domain.playcontext.Clock; +import com.loremind.domain.playcontext.Front; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.ports.ClockRepository; +import com.loremind.domain.playcontext.ports.FrontRepository; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.domain.playcontext.ports.SessionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * Test unitaire pour {@link SessionPrepService} (Phase 3 co-MJ — préparer la séance). + * Mocks des ports + des read-models amont (QuestStatusEnricher / CampaignReadinessService). + */ +@ExtendWith(MockitoExtension.class) +class SessionPrepServiceTest { + + private static final String PT = "pt-1"; + private static final String CAMP = "camp-1"; + + @Mock private PlaythroughRepository playthroughRepository; + @Mock private SessionRepository sessionRepository; + @Mock private ClockRepository clockRepository; + @Mock private FrontRepository frontRepository; + @Mock private QuestRepository questRepository; + @Mock private ChapterRepository chapterRepository; + @Mock private SceneRepository sceneRepository; + @Mock private QuestStatusEnricher statusEnricher; + @Mock private CampaignReadinessService readinessService; + + @InjectMocks private SessionPrepService service; + + @BeforeEach + void stubPlaythrough() { + // lenient : le test « playthrough inconnu » n'utilise pas ce stub (strict stubs sinon). + lenient().when(playthroughRepository.findById(PT)) + .thenReturn(Optional.of(Playthrough.builder().id(PT).campaignId(CAMP).name("Table").build())); + } + + private void stubEmptyAssessment() { + when(readinessService.assess(CAMP)).thenReturn(new CampaignReadinessAssessment( + CAMP, ReadinessStatus.POLISHED, Map.of(), List.of())); + } + + private static ReadinessGap gap(ReadinessEntityType type, String id, String chapterId) { + return new ReadinessGap(type, id, id, "RULE", "msg", ReadinessSeverity.BLOCKING, null, chapterId); + } + + @Test + void unknownPlaythrough_throws() { + when(playthroughRepository.findById("nope")).thenReturn(Optional.empty()); + assertThrows(IllegalArgumentException.class, () -> service.prepare("nope")); + } + + @Test + void partitionsQuestsByEffectiveStatus_andResolvesHotspots() { + Quest inProgress = Quest.builder().id("q-run").name("En cours").campaignId(CAMP).order(0) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0), + new QuestNodeRef(NodeType.SCENE, "scene-1", 1), + new QuestNodeRef(NodeType.SCENE, "ghost", 2))) // ref morte → ignorée + .build(); + Quest available = Quest.builder().id("q-next").name("Dispo").campaignId(CAMP).order(1).build(); + Quest locked = Quest.builder().id("q-lock").name("Verrouillée").campaignId(CAMP).order(2).build(); + Quest done = Quest.builder().id("q-done").name("Finie").campaignId(CAMP).order(3).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(inProgress, available, locked, done)); + when(statusEnricher.computeAll(anyList(), eq(PT))).thenReturn(Map.of( + "q-run", QuestStatus.IN_PROGRESS, + "q-next", QuestStatus.AVAILABLE, + "q-lock", QuestStatus.LOCKED, + "q-done", QuestStatus.COMPLETED)); + when(chapterRepository.findById("chap-1")) + .thenReturn(Optional.of(Chapter.builder().id("chap-1").name("Chap").arcId("arc-1").build())); + when(sceneRepository.findById("scene-1")) + .thenReturn(Optional.of(Scene.builder().id("scene-1").name("Scène").chapterId("chap-9").build())); + when(chapterRepository.findById("chap-9")) + .thenReturn(Optional.of(Chapter.builder().id("chap-9").name("Autre").arcId("arc-2").build())); + when(sceneRepository.findById("ghost")).thenReturn(Optional.empty()); + stubEmptyAssessment(); + + SessionPrepReport report = service.prepare(PT); + + assertEquals(List.of("q-run"), report.questsInProgress().stream() + .map(SessionPrepReport.QuestInfo::id).collect(Collectors.toList())); + assertEquals(List.of("q-next"), report.questsAvailable().stream() + .map(SessionPrepReport.QuestInfo::id).collect(Collectors.toList())); + assertEquals(List.of("q-done"), report.questsCompleted().stream() + .map(SessionPrepReport.QuestInfo::id).collect(Collectors.toList())); + // Hotspots : chapitre + scène résolus (avec contexte de navigation), ref morte ignorée. + assertEquals(2, report.hotspots().size()); + SessionPrepReport.NodeInfo chapterNode = report.hotspots().get(0); + assertEquals("CHAPTER", chapterNode.nodeType()); + assertEquals("arc-1", chapterNode.arcId()); + SessionPrepReport.NodeInfo sceneNode = report.hotspots().get(1); + assertEquals("SCENE", sceneNode.nodeType()); + assertEquals("arc-2", sceneNode.arcId()); + assertEquals("chap-9", sceneNode.chapterId()); + } + + @Test + void focusesGapsOnProbableContent_countsOthers() { + Quest active = Quest.builder().id("q-1").name("Q").campaignId(CAMP).order(0) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-hot", 0))).build(); + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of(active)); + when(statusEnricher.computeAll(anyList(), eq(PT))).thenReturn(Map.of("q-1", QuestStatus.IN_PROGRESS)); + when(chapterRepository.findById("chap-hot")) + .thenReturn(Optional.of(Chapter.builder().id("chap-hot").name("Hot").arcId("arc-1").build())); + + List allGaps = List.of( + gap(ReadinessEntityType.CHAPTER, "chap-hot", "chap-hot"), // ciblé (chapitre probable) + gap(ReadinessEntityType.SCENE, "scene-in-hot", "chap-hot"), // ciblé (scène du chapitre probable) + gap(ReadinessEntityType.QUEST, "q-1", null), // ciblé (quête active) + gap(ReadinessEntityType.SCENE, "scene-far", "chap-cold"), // ailleurs + gap(ReadinessEntityType.ARC, "arc-9", null)); // ailleurs + when(readinessService.assess(CAMP)).thenReturn(new CampaignReadinessAssessment( + CAMP, ReadinessStatus.DRAFT, Map.of(), allGaps)); + + SessionPrepReport report = service.prepare(PT); + + assertEquals(3, report.gaps().size()); + assertEquals(2, report.otherGapCount()); + } + + @Test + void noQuests_allGapsAreKept() { + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of()); + when(statusEnricher.computeAll(anyList(), eq(PT))).thenReturn(Map.of()); + List allGaps = List.of( + gap(ReadinessEntityType.SCENE, "s-1", "c-1"), + gap(ReadinessEntityType.ARC, "a-1", null)); + when(readinessService.assess(CAMP)).thenReturn(new CampaignReadinessAssessment( + CAMP, ReadinessStatus.DRAFT, Map.of(), allGaps)); + + SessionPrepReport report = service.prepare(PT); + + assertEquals(2, report.gaps().size()); + assertEquals(0, report.otherGapCount()); + } + + @Test + void clocks_onlyStartedOnes_withFrontName() { + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of()); + when(statusEnricher.computeAll(anyList(), eq(PT))).thenReturn(Map.of()); + stubEmptyAssessment(); + when(frontRepository.findByPlaythroughId(PT)).thenReturn(List.of( + Front.builder().id("f-1").playthroughId(PT).name("La montée du Culte").build())); + when(clockRepository.findByPlaythroughId(PT)).thenReturn(List.of( + Clock.builder().id("cl-1").playthroughId(PT).name("Rituel").segments(4).filled(3).frontId("f-1").build(), + Clock.builder().id("cl-2").playthroughId(PT).name("Intacte").segments(6).filled(0).build())); + + SessionPrepReport report = service.prepare(PT); + + assertEquals(1, report.clocks().size()); + SessionPrepReport.ClockInfo clock = report.clocks().get(0); + assertEquals("Rituel", clock.name()); + assertEquals(3, clock.filled()); + assertEquals("La montée du Culte", clock.frontName()); + } + + @Test + void lastSession_isTheLatestByStartDate() { + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of()); + when(statusEnricher.computeAll(anyList(), eq(PT))).thenReturn(Map.of()); + stubEmptyAssessment(); + LocalDateTime old = LocalDateTime.of(2026, 6, 1, 20, 0); + LocalDateTime recent = LocalDateTime.of(2026, 6, 23, 20, 0); + when(sessionRepository.findByPlaythroughId(PT)).thenReturn(List.of( + Session.builder().id("s-old").name("Séance 1").playthroughId(PT).startedAt(old).endedAt(old.plusHours(4)).build(), + Session.builder().id("s-new").name("Séance 2").playthroughId(PT).startedAt(recent).endedAt(recent.plusHours(3)).build())); + + SessionPrepReport report = service.prepare(PT); + + assertNotNull(report.lastSession()); + assertEquals("s-new", report.lastSession().id()); + assertFalse(report.lastSession().active()); + } + + @Test + void noSessions_lastSessionIsNull() { + when(questRepository.findByCampaignId(CAMP)).thenReturn(List.of()); + when(statusEnricher.computeAll(anyList(), eq(PT))).thenReturn(Map.of()); + stubEmptyAssessment(); + + assertNull(service.prepare(PT).lastSession()); + } +} diff --git a/core/src/test/java/com/loremind/application/playcontext/SessionRecapServiceTest.java b/core/src/test/java/com/loremind/application/playcontext/SessionRecapServiceTest.java new file mode 100644 index 0000000..bb79023 --- /dev/null +++ b/core/src/test/java/com/loremind/application/playcontext/SessionRecapServiceTest.java @@ -0,0 +1,92 @@ +package com.loremind.application.playcontext; + +import com.loremind.domain.playcontext.EntryType; +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.SessionEntry; +import com.loremind.domain.playcontext.ports.SessionEntryRepository; +import com.loremind.domain.playcontext.ports.SessionRecapAssistant; +import com.loremind.domain.playcontext.ports.SessionRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; + +/** + * Test unitaire pour SessionRecapService — sélection de la séance précédente + * + construction du transcript envoyé au port IA. + */ +@ExtendWith(MockitoExtension.class) +class SessionRecapServiceTest { + + private static final LocalDateTime T1 = LocalDateTime.of(2026, 6, 1, 20, 0); + private static final LocalDateTime T2 = LocalDateTime.of(2026, 6, 23, 20, 0); + private static final LocalDateTime T3 = LocalDateTime.of(2026, 7, 3, 20, 0); + + @Mock private SessionRepository sessionRepository; + @Mock private SessionEntryRepository entryRepository; + @Mock private SessionRecapAssistant recapAssistant; + + @InjectMocks private SessionRecapService service; + + private static Session session(String id, LocalDateTime startedAt) { + return Session.builder().id(id).name("Séance " + id).playthroughId("pt-1") + .startedAt(startedAt).endedAt(startedAt.plusHours(3)).build(); + } + + private static SessionEntry entry(String content) { + return SessionEntry.builder().id("e").sessionId("prev").type(EntryType.EVENT) + .content(content).occurredAt(T2).build(); + } + + @Test + void picksTheMostRecentSessionBeforeCurrent_andBuildsTranscript() { + Session current = session("cur", T3); + when(sessionRepository.findById("cur")).thenReturn(Optional.of(current)); + when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( + session("old", T1), session("prev", T2), current)); + when(entryRepository.findBySessionId("prev")).thenReturn(List.of( + entry("Les PJ ont sauvé le convoi."), entry("La Bulette s'est enfuie."))); + when(recapAssistant.generateRecap(anyString(), anyString())).thenReturn("Précédemment…"); + + SessionRecapService.RecapResult result = service.recapPreviousSession("cur"); + + assertEquals("Précédemment…", result.recap()); + assertEquals("Séance prev", result.previousSessionName()); + ArgumentCaptor transcript = ArgumentCaptor.forClass(String.class); + verify(recapAssistant).generateRecap(transcript.capture(), anyString()); + assertTrue(transcript.getValue().contains("[EVENT] Les PJ ont sauvé le convoi.")); + assertTrue(transcript.getValue().contains("La Bulette")); + } + + @Test + void noPreviousSession_throwsWithClearMessage() { + Session current = session("cur", T1); + when(sessionRepository.findById("cur")).thenReturn(Optional.of(current)); + when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of(current)); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> service.recapPreviousSession("cur")); + assertTrue(ex.getMessage().contains("Aucune séance précédente")); + } + + @Test + void previousSessionWithEmptyJournal_throws() { + Session current = session("cur", T3); + when(sessionRepository.findById("cur")).thenReturn(Optional.of(current)); + when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of(session("prev", T2), current)); + when(entryRepository.findBySessionId("prev")).thenReturn(List.of()); + + assertThrows(IllegalArgumentException.class, () -> service.recapPreviousSession("cur")); + } +} diff --git a/core/src/test/java/com/loremind/application/playcontext/SessionServiceTest.java b/core/src/test/java/com/loremind/application/playcontext/SessionServiceTest.java new file mode 100644 index 0000000..ca1daac --- /dev/null +++ b/core/src/test/java/com/loremind/application/playcontext/SessionServiceTest.java @@ -0,0 +1,63 @@ +package com.loremind.application.playcontext; + +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.domain.playcontext.ports.SessionEntryRepository; +import com.loremind.domain.playcontext.ports.SessionRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Test unitaire pour SessionService — scène courante épinglée (mode cockpit). + * Le cycle de vie (start/end/rename) est couvert par les tests d'intégration controller. + */ +@ExtendWith(MockitoExtension.class) +class SessionServiceTest { + + @Mock private SessionRepository sessionRepository; + @Mock private SessionEntryRepository entryRepository; + @Mock private PlaythroughRepository playthroughRepository; + @Mock private ClockService clockService; + + @InjectMocks private SessionService service; + + @Test + void setCurrentScene_pinsScene() { + Session session = Session.builder().id("s-1").name("S").playthroughId("pt-1").build(); + when(sessionRepository.findById("s-1")).thenReturn(Optional.of(session)); + when(sessionRepository.save(any(Session.class))).thenAnswer(inv -> inv.getArgument(0)); + + Session updated = service.setCurrentScene("s-1", "scene-42"); + + assertEquals("scene-42", updated.getCurrentSceneId()); + } + + @Test + void setCurrentScene_blankOrNull_unpins() { + Session session = Session.builder().id("s-1").name("S").currentSceneId("scene-42").build(); + when(sessionRepository.findById("s-1")).thenReturn(Optional.of(session)); + when(sessionRepository.save(any(Session.class))).thenAnswer(inv -> inv.getArgument(0)); + + assertNull(service.setCurrentScene("s-1", null).getCurrentSceneId()); + session.setCurrentSceneId("scene-42"); + assertNull(service.setCurrentScene("s-1", " ").getCurrentSceneId()); + } + + @Test + void setCurrentScene_unknownSession_throws() { + when(sessionRepository.findById("nope")).thenReturn(Optional.empty()); + assertThrows(IllegalArgumentException.class, () -> service.setCurrentScene("nope", "scene-1")); + verify(sessionRepository, never()).save(any()); + } +} diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java index 5e195bc..0ad1315 100644 --- a/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java +++ b/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java @@ -12,8 +12,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** * Tests unitaires du domaine pour Scene. * Scene est la plus riche en champs : on valide les collections @Builder.Default - * (relatedPageIds, illustrationImageIds, branches), la battlemap (paire media + - * sidecar, nullable) et la preservation de l'ensemble des champs narratifs. + * (relatedPageIds, illustrationImageIds, branches, battlemaps) et la preservation + * de l'ensemble des champs narratifs. */ class SceneTest { @@ -32,20 +32,26 @@ class SceneTest { assertTrue(scene.getRelatedPageIds().isEmpty()); assertTrue(scene.getIllustrationImageIds().isEmpty()); assertTrue(scene.getBranches().isEmpty()); - // Battlemap : aucune carte par defaut (les deux refs sont null). - assertNull(scene.getBattlemapMediaFileId()); - assertNull(scene.getBattlemapDataFileId()); + // Battlemaps : aucune carte par defaut (liste vide, jamais null). + assertNotNull(scene.getBattlemaps()); + assertTrue(scene.getBattlemaps().isEmpty()); } @Test - void builder_preservesBattlemap_whenProvided() { + void builder_preservesBattlemaps_whenProvided() { Scene scene = Scene.builder() - .battlemapMediaFileId("42") - .battlemapDataFileId("43") + .battlemaps(List.of( + new SceneBattlemap("Jour", "42", "43"), + new SceneBattlemap("Nuit", "44", null))) .build(); - assertEquals("42", scene.getBattlemapMediaFileId()); - assertEquals("43", scene.getBattlemapDataFileId()); + assertEquals(2, scene.getBattlemaps().size()); + assertEquals("Jour", scene.getBattlemaps().get(0).label()); + assertEquals("42", scene.getBattlemaps().get(0).mediaFileId()); + assertEquals("43", scene.getBattlemaps().get(0).dataFileId()); + assertNull(scene.getBattlemaps().get(1).dataFileId()); + // Libelle absent normalise en chaine vide par le constructeur canonique. + assertEquals("", new SceneBattlemap(null, "1", null).label()); } @Test diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java index bb6c8d3..b6edea3 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java @@ -1,14 +1,17 @@ package com.loremind.infrastructure.persistence; import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; import org.flywaydb.core.api.output.MigrateResult; import org.junit.jupiter.api.Test; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -45,8 +48,11 @@ class FlywayMigrationTest { // V2.1 : la table stored_files existe et est interrogeable. st.executeQuery("select id, filename, content_type, size_bytes, storage_key, uploaded_at from stored_files"); - // V2.2 : la scene porte la battlemap. - st.executeQuery("select battlemap_media_file_id, battlemap_data_file_id from scenes"); + // V22 : la scene porte la LISTE de battlemaps ; la paire V2 a disparu. + st.executeQuery("select battlemaps from scenes"); + assertThrows(SQLException.class, + () -> st.executeQuery("select battlemap_media_file_id from scenes"), + "scenes.battlemap_media_file_id aurait du etre supprimee par V22"); // V2.3 : les anciennes colonnes "cartes / plans" ont bien disparu. assertThrows(SQLException.class, @@ -58,6 +64,59 @@ class FlywayMigrationTest { assertThrows(SQLException.class, () -> st.executeQuery("select map_image_ids from chapters"), "chapters.map_image_ids aurait du etre supprimee par V2"); + + // V21 : le CHECK V1 sur arcs.type est tombe -> le type SYSTEM + // (arc technique « Quêtes libres ») est accepte. + st.executeUpdate("insert into campaigns (name, arcs_count, created_at, updated_at) " + + "values ('C', 0, now(), now())"); + st.executeUpdate("insert into arcs (name, campaign_id, \"order\", type, created_at, updated_at) " + + "select 'Quêtes libres', id, 9999, 'SYSTEM', now(), now() from campaigns"); + } + } + + @Test + void v22_backfillsLegacyBattlemapPair_intoJsonList() throws SQLException { + String url = "jdbc:h2:mem:flyway_v22_test;MODE=PostgreSQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1"; + + // 1) Schema arrete AVANT V22 (paire de colonnes encore presente)… + Flyway.configure() + .dataSource(url, "sa", "") + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("21")) + .load() + .migrate(); + + // 2) …peuple d'une scene AVEC carte (media+sidecar) et d'une scene SANS. + try (Connection conn = DriverManager.getConnection(url, "sa", ""); + Statement st = conn.createStatement()) { + st.executeUpdate("insert into campaigns (id, name, arcs_count, created_at, updated_at) " + + "values (1, 'C', 0, now(), now())"); + st.executeUpdate("insert into arcs (id, name, campaign_id, \"order\", created_at, updated_at) " + + "values (1, 'A', 1, 0, now(), now())"); + st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) " + + "values (1, 'Ch', 1, 0, now(), now())"); + st.executeUpdate("insert into scenes (id, name, chapter_id, \"order\", " + + "battlemap_media_file_id, battlemap_data_file_id, created_at, updated_at) " + + "values (1, 'Avec carte', 1, 0, '100', '101', now(), now())"); + st.executeUpdate("insert into scenes (id, name, chapter_id, \"order\", created_at, updated_at) " + + "values (2, 'Sans carte', 1, 1, now(), now())"); + } + + // 3) Fin de la chaine : V22 transforme la paire en liste JSON. + Flyway.configure() + .dataSource(url, "sa", "") + .locations("classpath:db/migration") + .load() + .migrate(); + + try (Connection conn = DriverManager.getConnection(url, "sa", ""); + Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("select id, battlemaps from scenes order by id")) { + rs.next(); + assertEquals("[{\"label\":\"\",\"mediaFileId\":\"100\",\"dataFileId\":\"101\"}]", + rs.getString(2)); + rs.next(); + assertEquals("[]", rs.getString(2)); } } } diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunnerProgressionTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunnerProgressionTest.java new file mode 100644 index 0000000..bd562a7 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunnerProgressionTest.java @@ -0,0 +1,79 @@ +package com.loremind.infrastructure.persistence.migration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Régression (Niveau 1) : le runner legacy {@code PlaythroughMigrationRunner.migrateProgressions} + * insérait dans {@code quest_progression.chapter_id}, colonne renommée en {@code quest_id} par V11. + * Les tests create-drop ne couvraient pas ce chemin (la colonne legacy + * {@code chapters.progression_status} n'existe pas dans le schéma Hibernate), d'où un crash au + * démarrage sur une BDD Postgres existante. Ce test rejoue le runner sur un schéma H2 + * {@code MODE=PostgreSQL} contenant la colonne legacy + un {@code quest_progression} post-V11. + */ +class PlaythroughMigrationRunnerProgressionTest { + + private String url; + private Connection keepAlive; + private JdbcTemplate jdbc; + + @BeforeEach + void setUp() throws Exception { + // DATABASE_TO_LOWER=TRUE : H2 plie les identifiants non quotés en minuscules comme Postgres, + // pour que les sondes information_schema (table_name/column_name en minuscules) du runner matchent. + url = "jdbc:h2:mem:pmr_" + UUID.randomUUID().toString().replace("-", "") + + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"; + keepAlive = DriverManager.getConnection(url, "sa", ""); + jdbc = new JdbcTemplate(new DriverManagerDataSource(url, "sa", "")); + + // chapters porte la colonne LEGACY progression_status ; quest_progression est en schéma post-V11. + jdbc.execute("create table campaigns (id bigint primary key)"); + jdbc.execute("create table arcs (id bigint primary key, campaign_id bigint not null)"); + jdbc.execute("create table chapters (id bigint primary key, arc_id bigint not null, progression_status varchar(16))"); + jdbc.execute("create table playthroughs (id bigint primary key, campaign_id bigint not null)"); + jdbc.execute("create table quest_progression (" + + "id bigint generated by default as identity primary key, " + + "playthrough_id bigint not null, quest_id bigint not null, status varchar(16) not null)"); + } + + @AfterEach + void tearDown() throws Exception { + if (keepAlive != null) keepAlive.close(); + } + + @Test + void migrateProgressions_usesQuestIdColumn_andIsIdempotent() { + jdbc.update("insert into campaigns (id) values (1)"); + jdbc.update("insert into arcs (id, campaign_id) values (10, 1)"); + jdbc.update("insert into chapters (id, arc_id, progression_status) values (100, 10, 'IN_PROGRESS')"); + jdbc.update("insert into chapters (id, arc_id, progression_status) values (101, 10, 'NOT_STARTED')"); // ignoré + jdbc.update("insert into playthroughs (id, campaign_id) values (50, 1)"); + + PlaythroughMigrationRunner runner = new PlaythroughMigrationRunner(); + + int migrated = runner.migrateProgressions(jdbc); + assertEquals(1, migrated, "Seul le chapitre 100 (IN_PROGRESS) doit migrer."); + + List> rows = jdbc.queryForList( + "select playthrough_id, quest_id, status from quest_progression"); + assertEquals(1, rows.size()); + assertEquals(50L, ((Number) rows.get(0).get("playthrough_id")).longValue()); + assertEquals(100L, ((Number) rows.get(0).get("quest_id")).longValue(), + "quest_id == id du chapitre (continuité d'id)."); + assertEquals("IN_PROGRESS", rows.get(0).get("status")); + + // Idempotence : 2e passage n'insère rien (NOT EXISTS sur quest_id). + assertEquals(0, runner.migrateProgressions(jdbc)); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/migration/QuestBackfillMigrationTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/migration/QuestBackfillMigrationTest.java new file mode 100644 index 0000000..bbaa25e --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/migration/QuestBackfillMigrationTest.java @@ -0,0 +1,183 @@ +package com.loremind.infrastructure.persistence.migration; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.configuration.FluentConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Harnais de migration (Niveau 1, Phase 0) — exigé par la critique adverse : la + * suite normale tourne en {@code ddl-auto=create-drop} avec Flyway DÉSACTIVÉ, donc + * AUCUN script de migration n'y est exécuté. Ce test rejoue les VRAIES migrations + * Flyway (V1→V10) sur H2 en {@code MODE=PostgreSQL;NON_KEYWORDS=VALUE} — exactement + * le moteur de la distribution desktop — pour valider le backfill V10 : + *
    + *
  • prédicat aligné sur la règle runtime (HUB OU porte des prérequis OU déjà + * référencé par une progression) — y compris un chapitre LINÉAIRE conditionnel ;
  • + *
  • zéro perte (toute progression existante reste rattachable) ;
  • + *
  • continuité d'id (Quest.id == Chapter.id) et prérequis préservés verbatim ;
  • + *
  • anti-collision : une Quest créée APRÈS la migration obtient un id frais.
  • + *
+ */ +class QuestBackfillMigrationTest { + + private String url; + private Connection keepAlive; // garde la base in-mem vivante pour toute la durée du test + + @BeforeEach + void setUp() throws Exception { + // Base unique par exécution + MODE/flags identiques au profil desktop (application-local.properties). + url = "jdbc:h2:mem:questmig_" + UUID.randomUUID().toString().replace("-", "") + + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1;NON_KEYWORDS=VALUE"; + keepAlive = DriverManager.getConnection(url, "sa", ""); + } + + @AfterEach + void tearDown() throws Exception { + if (keepAlive != null) keepAlive.close(); + } + + @Test + void backfills_quests_from_quest_like_chapters_only() throws Exception { + // 1) Schéma jusqu'à l'état pré-Niveau 1 (V1..V8). + flyway().target("8").load().migrate(); + + // 2) Seed d'un état réaliste. + seed(); + + // 3) Applique V9 (table quests) + V10 (backfill). + flyway().load().migrate(); + + // 4) Vérifications. + List questIds = questIds(); + assertEquals(List.of(100L, 101L, 102L, 104L), questIds, + "Doivent devenir des quêtes : 100 (HUB+prereq), 101 (HUB), 102 (LINÉAIRE+prereq), " + + "104 (LINÉAIRE sans prereq mais référencé par une progression). PAS 103 (LINÉAIRE nu)."); + + assertFalse(questIds.contains(103L), "Un chapitre linéaire SANS prérequis ni progression n'est pas une quête."); + + // Continuité d'id + champs préservés. + assertEquals(1L, scalarLong("SELECT campaign_id FROM quests WHERE id = 100")); + assertEquals("Ch HUB prereq", scalarString("SELECT name FROM quests WHERE id = 100")); + String prereq100 = scalarString("SELECT prerequisites FROM quests WHERE id = 100"); + assertTrue(prereq100.contains("FLAG_SET") && prereq100.contains("f1"), + "Les prérequis du chapitre doivent être copiés verbatim sur la quête : " + prereq100); + + // Le nœud d'origine (chapitre) est référencé. + String nodes100 = scalarString("SELECT nodes FROM quests WHERE id = 100"); + assertTrue(nodes100.contains("\"nodeType\":\"CHAPTER\"") && nodes100.contains("\"nodeId\":\"100\""), + "La quête doit référencer son chapitre d'origine comme premier nœud : " + nodes100); + + // Cas critique : chapitre LINÉAIRE à prérequis (déjà une quête fonctionnelle aujourd'hui). + String prereq102 = scalarString("SELECT prerequisites FROM quests WHERE id = 102"); + assertTrue(prereq102.contains("SESSION_REACHED"), "Le chapitre linéaire conditionnel doit être migré : " + prereq102); + + // V11 : quest_progression a basculé chapter_id -> quest_id sans perte (continuité d'id). + assertEquals(List.of(100L, 104L), longList("SELECT quest_id FROM quest_progression ORDER BY quest_id"), + "Les progressions seedées (chapitres 100 et 104) doivent pointer quest_id = ancien chapter_id."); + + // 5) Anti-collision : une Quest créée après la migration ne réutilise pas un id backfillé. + long newId = insertFreshQuestAndReturnId(); + assertTrue(newId > 104L, "Une Quest créée après le backfill doit obtenir un id frais (> max backfillé), obtenu : " + newId); + } + + // ─────────────────────────── helpers ─────────────────────────── + + private FluentConfiguration flyway() { + return Flyway.configure() + .dataSource(url, "sa", "") + .locations("classpath:db/migration"); + } + + private void seed() throws Exception { + exec("INSERT INTO campaigns (id, arcs_count, name, created_at, updated_at) " + + "VALUES (1, 2, 'C', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + exec("INSERT INTO arcs (id, \"order\", campaign_id, type, name, created_at, updated_at) " + + "VALUES (10, 1, 1, 'HUB', 'Hub Arc', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + exec("INSERT INTO arcs (id, \"order\", campaign_id, type, name, created_at, updated_at) " + + "VALUES (11, 2, 1, 'LINEAR', 'Linear Arc', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + + // 100 : HUB + prérequis + exec("INSERT INTO chapters (id, \"order\", arc_id, name, prerequisites, created_at, updated_at) " + + "VALUES (100, 1, 10, 'Ch HUB prereq', '[{\"kind\":\"FLAG_SET\",\"flagName\":\"f1\"}]', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + // 101 : HUB sans prérequis ([]) + exec("INSERT INTO chapters (id, \"order\", arc_id, name, prerequisites, created_at, updated_at) " + + "VALUES (101, 2, 10, 'Ch HUB', '[]', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + // 102 : LINÉAIRE + prérequis (= déjà une quête fonctionnelle) + exec("INSERT INTO chapters (id, \"order\", arc_id, name, prerequisites, created_at, updated_at) " + + "VALUES (102, 1, 11, 'Ch LIN prereq', '[{\"kind\":\"SESSION_REACHED\",\"minSessionNumber\":2}]', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + // 103 : LINÉAIRE nu (PAS une quête) + exec("INSERT INTO chapters (id, \"order\", arc_id, name, prerequisites, created_at, updated_at) " + + "VALUES (103, 2, 11, 'Ch LIN nu', '[]', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + // 104 : LINÉAIRE nu MAIS référencé par une progression + exec("INSERT INTO chapters (id, \"order\", arc_id, name, created_at, updated_at) " + + "VALUES (104, 3, 11, 'Ch LIN progressed', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + + exec("INSERT INTO playthroughs (id, campaign_id, name, created_at, updated_at) " + + "VALUES (50, 1, 'P', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + exec("INSERT INTO quest_progression (playthrough_id, chapter_id, status) VALUES (50, 100, 'COMPLETED')"); + exec("INSERT INTO quest_progression (playthrough_id, chapter_id, status) VALUES (50, 104, 'IN_PROGRESS')"); + } + + private void exec(String sql) throws Exception { + try (Statement st = keepAlive.createStatement()) { + st.executeUpdate(sql); + } + } + + private List questIds() throws Exception { + return longList("SELECT id FROM quests ORDER BY id"); + } + + private List longList(String sql) throws Exception { + List ids = new ArrayList<>(); + try (Statement st = keepAlive.createStatement(); + ResultSet rs = st.executeQuery(sql)) { + while (rs.next()) ids.add(rs.getLong(1)); + } + return ids; + } + + private long scalarLong(String sql) throws Exception { + try (Statement st = keepAlive.createStatement(); + ResultSet rs = st.executeQuery(sql)) { + assertTrue(rs.next(), "Aucun résultat pour : " + sql); + return rs.getLong(1); + } + } + + private String scalarString(String sql) throws Exception { + try (Statement st = keepAlive.createStatement(); + ResultSet rs = st.executeQuery(sql)) { + assertTrue(rs.next(), "Aucun résultat pour : " + sql); + return rs.getString(1); + } + } + + private long insertFreshQuestAndReturnId() throws Exception { + try (PreparedStatement ps = keepAlive.prepareStatement( + "INSERT INTO quests (campaign_id, \"order\", name, created_at, updated_at) " + + "VALUES (1, 1, 'New', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + Statement.RETURN_GENERATED_KEYS)) { + ps.executeUpdate(); + try (ResultSet rs = ps.getGeneratedKeys()) { + assertTrue(rs.next(), "Pas de clé générée pour la nouvelle Quest"); + return rs.getLong(1); + } + } + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java index 5fed042..4ca76ac 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java @@ -4,6 +4,7 @@ import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneBattlemap; import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository; @@ -54,7 +55,8 @@ class PostgresSceneRepositoryTest { .choicesConsequences("Si attaque -> gardes").combatDifficulty("facile").enemies("3 brigands") .relatedPageIds(List.of("page-aubergiste")) .illustrationImageIds(List.of("img-1", "img-2")) - .battlemapMediaFileId("100").battlemapDataFileId("101") + .battlemaps(List.of(new SceneBattlemap("Jour", "100", "101"), + new SceneBattlemap("Nuit", "102", null))) .build(); Scene saved = repository.save(scene); @@ -65,8 +67,12 @@ class PostgresSceneRepositoryTest { assertEquals("Taverne du Dragon d'Or", r.getLocation()); assertEquals("Piege cache", r.getGmSecretNotes()); assertEquals(2, r.getIllustrationImageIds().size()); - assertEquals("100", r.getBattlemapMediaFileId()); - assertEquals("101", r.getBattlemapDataFileId()); + // Battlemaps : round-trip JSON du record (labels + refs preserves). + assertEquals(2, r.getBattlemaps().size()); + assertEquals("Jour", r.getBattlemaps().get(0).label()); + assertEquals("100", r.getBattlemaps().get(0).mediaFileId()); + assertEquals("101", r.getBattlemaps().get(0).dataFileId()); + assertEquals("102", r.getBattlemaps().get(1).mediaFileId()); } @Test diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java index 21f59c8..a804396 100644 --- a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java +++ b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java @@ -1,11 +1,15 @@ package com.loremind.infrastructure.transfer; import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.NodeType; import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.QuestNodeRef; import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.campaigncontext.SceneBattlemap; import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.images.ports.ImageStorage; +import com.loremind.domain.playcontext.ClockTrigger; import com.loremind.domain.playcontext.EntryType; import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.jpa.*; @@ -71,8 +75,11 @@ class ContentExportImportRoundTripTest { @Autowired private PlaythroughJpaRepository playthroughRepo; @Autowired private SessionJpaRepository sessionRepo; @Autowired private SessionEntryJpaRepository sessionEntryRepo; + @Autowired private ClockJpaRepository clockRepo; + @Autowired private FrontJpaRepository frontRepo; @Autowired private PlaythroughFlagJpaRepository playthroughFlagRepo; @Autowired private QuestProgressionJpaRepository questProgressionRepo; + @Autowired private QuestJpaRepository questRepo; @MockitoBean private ImageStorage imageStorage; @MockitoBean private FileStorage fileStorage; @@ -120,11 +127,17 @@ class ContentExportImportRoundTripTest { .name("RT Chapter A").arcId(arc.getId()).order(0).build()); chapterRepo.save(ChapterJpaEntity.builder() .name("RT Chapter B").arcId(arc.getId()).order(1) - .prerequisites(new ArrayList<>(List.of(new Prerequisite.QuestCompleted(String.valueOf(chapterA.getId()))))) .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))).build()); ChapterJpaEntity chapterB = chapterRepo.findAll().stream() .filter(c -> "RT Chapter B".equals(c.getName())).findFirst().orElseThrow(); + // Quête (Niveau 1) : prérequis (flag), nœud vers chapterA (à remapper), page liée (à remapper). + QuestJpaEntity quest = questRepo.save(QuestJpaEntity.builder() + .campaignId(campaign.getId()).name("RT Quest").order(0) + .prerequisites(new ArrayList<>(List.of(new Prerequisite.FlagSet("porte_ouverte")))) + .nodes(new ArrayList<>(List.of(new QuestNodeRef(NodeType.CHAPTER, String.valueOf(chapterA.getId()), 0)))) + .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))).build()); + EnemyJpaEntity enemy = enemyRepo.save(EnemyJpaEntity.builder() .name("RT Enemy").campaignId(campaign.getId()).level("3").folder("Cave") .foundryRef("Compendium.x").foundryStats(Map.of("hp", "11")).order(0).build()); @@ -134,7 +147,8 @@ class ContentExportImportRoundTripTest { .enemyIds(new ArrayList<>(List.of(String.valueOf(enemy.getId())))) .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))) .illustrationImageIds(new ArrayList<>(List.of(imageRef))) - .battlemapMediaFileId(String.valueOf(storedFile.getId())) + .battlemaps(new ArrayList<>(List.of( + new SceneBattlemap("Nuit", String.valueOf(storedFile.getId()), null)))) .branches(new ArrayList<>(List.of(new SceneBranch("Si fuite", null, "cond")))).build()); npcRepo.save(NpcJpaEntity.builder() @@ -166,8 +180,16 @@ class ContentExportImportRoundTripTest { .occurredAt(LocalDateTime.of(2026, 1, 1, 20, 5)).build()); playthroughFlagRepo.save(PlaythroughFlagJpaEntity.builder() .playthroughId(pt.getId()).name("porte_ouverte").value(true).build()); + FrontJpaEntity front = frontRepo.save(FrontJpaEntity.builder() + .playthroughId(pt.getId()).name("RT Front").description("menace").order(0).build()); + clockRepo.save(ClockJpaEntity.builder() + .playthroughId(pt.getId()).name("RT Horloge").description("Quand pleine : boom") + .segments(6).filled(2).order(0) + .triggerType(ClockTrigger.QUEST_COMPLETED).triggerRef(String.valueOf(quest.getId())) + .frontId(front.getId()).build()); + // Progression d'une VRAIE quête (modèle Niveau 1) : référence le quest id. questProgressionRepo.save(QuestProgressionJpaEntity.builder() - .playthroughId(pt.getId()).chapterId(chapterA.getId()).status(ProgressionStatus.IN_PROGRESS).build()); + .playthroughId(pt.getId()).questId(quest.getId()).status(ProgressionStatus.IN_PROGRESS).build()); characterRepo.save(CharacterJpaEntity.builder() .name("RT Hero").campaignId(campaign.getId()).playthroughId(pt.getId()).order(0).build()); @@ -181,6 +203,7 @@ class ContentExportImportRoundTripTest { long entriesBefore = sessionEntryRepo.count(); long flagsBefore = playthroughFlagRepo.count(); long questsBefore = questProgressionRepo.count(); + long questEntitiesBefore = questRepo.count(); long charactersBefore = characterRepo.count(); Long campaignId0 = campaign.getId(); Long pageId0 = page.getId(); @@ -257,10 +280,48 @@ class ContentExportImportRoundTripTest { .findFirst().orElseThrow(); assertEquals("Début", importedEntry.getContent()); - QuestProgressionJpaEntity importedQuest = questProgressionRepo.findAll().stream() + // 4d. Quête (Niveau 1) : dupliquée + références remappées (nœud chapitre, page liée). + assertEquals(2 * questEntitiesBefore, questRepo.count()); + QuestJpaEntity importedQuestEntity = questRepo.findAll().stream() + .filter(q -> "RT Quest".equals(q.getName()) && importedCampaignId.equals(q.getCampaignId())) + .findFirst().orElseThrow(); + assertEquals(1, importedQuestEntity.getNodes().size()); + assertEquals(String.valueOf(importedChapterAId), importedQuestEntity.getNodes().get(0).nodeId(), + "Le nœud CHAPTER de la quête doit pointer le chapitre importé."); + assertTrue(importedQuestEntity.getRelatedPageIds().contains(String.valueOf(importedPageId)), + "relatedPageIds de la quête remappé vers la page importée."); + assertEquals(1, importedQuestEntity.getPrerequisites().size()); + + // 4e. quest_progression importée pointe la QUÊTE importée (remap v2 via questMap). + QuestProgressionJpaEntity importedQuestProg = questProgressionRepo.findAll().stream() .filter(q -> importedPtId.equals(q.getPlaythroughId())) .findFirst().orElseThrow(); - assertEquals(importedChapterAId, importedQuest.getChapterId()); + assertEquals(importedQuestEntity.getId(), importedQuestProg.getQuestId()); + + // 4f. Battlemaps : la liste étiquetée survit au round-trip (originale ET importée — + // le binaire est réutilisé par clé, la ref StoredFile reste valide telle quelle). + List rtScenes = sceneRepo.findAll().stream() + .filter(s -> "RT Scene".equals(s.getName())).toList(); + assertEquals(2, rtScenes.size()); + for (SceneJpaEntity s : rtScenes) { + assertEquals(1, s.getBattlemaps().size(), "1 battlemap attendue sur " + s.getId()); + assertEquals("Nuit", s.getBattlemaps().get(0).label()); + } + + // 4f. Horloge importée : pointe la Partie importée + valeurs (segments/filled) préservées. + ClockJpaEntity importedClock = clockRepo.findAll().stream() + .filter(c -> "RT Horloge".equals(c.getName()) && importedPtId.equals(c.getPlaythroughId())) + .findFirst().orElseThrow(); + assertEquals(6, importedClock.getSegments()); + assertEquals(2, importedClock.getFilled()); + // Trigger QUEST_COMPLETED : le triggerRef (id de quête) est remappé vers la quête importée. + assertEquals(ClockTrigger.QUEST_COMPLETED, importedClock.getTriggerType()); + assertEquals(String.valueOf(importedQuestEntity.getId()), importedClock.getTriggerRef()); + // 4g. Front importé + horloge rattachée à ce front (frontId remappé). + FrontJpaEntity importedFront = frontRepo.findAll().stream() + .filter(f -> "RT Front".equals(f.getName()) && importedPtId.equals(f.getPlaythroughId())) + .findFirst().orElseThrow(); + assertEquals(importedFront.getId(), importedClock.getFrontId()); } private static Map readZip(byte[] bytes) throws IOException { diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java index 9e23900..cd9cc35 100644 --- a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java +++ b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java @@ -1,7 +1,15 @@ package com.loremind.infrastructure.transfer; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.infrastructure.persistence.entity.ChapterJpaEntity; +import com.loremind.infrastructure.persistence.entity.QuestJpaEntity; +import com.loremind.infrastructure.persistence.entity.QuestProgressionJpaEntity; import com.loremind.infrastructure.persistence.jpa.ArcJpaRepository; import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository; +import com.loremind.infrastructure.persistence.jpa.ChapterJpaRepository; +import com.loremind.infrastructure.persistence.jpa.QuestJpaRepository; +import com.loremind.infrastructure.persistence.jpa.QuestProgressionJpaRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -30,6 +38,9 @@ class ContentExportV1ImportTest { @Autowired private ImportService importService; @Autowired private CampaignJpaRepository campaignRepo; @Autowired private ArcJpaRepository arcRepo; + @Autowired private ChapterJpaRepository chapterRepo; + @Autowired private QuestJpaRepository questRepo; + @Autowired private QuestProgressionJpaRepository questProgressionRepo; @Test void importsLegacyV1ArchiveWithoutPlaySectionOrScope() throws Exception { @@ -64,6 +75,65 @@ class ContentExportV1ImportTest { assertTrue(campaignRepo.findAll().stream().anyMatch(c -> "V1 Camp".equals(c.getName()))); } + @Test + void importsLegacyBundleWithoutQuests_convertsHubChaptersToQuests() throws Exception { + // Bundle "intermédiaire" : a l'espace de jeu (quest_progression) MAIS PAS de champ quests[] + // (exporté après l'espace de jeu, avant l'entité Quête). Les chapitres HUB (+ leurs + // prérequis) doivent être convertis en vraies Quests à l'import. + String dataJson = """ + { + "manifest": { "formatVersion": 2, "appVersion": "1.0.0-pre", "exportedAt": "2025-06-01T00:00:00Z", "scope": "complète" }, + "campaigns": [ + { "id": 1, "name": "Legacy Camp", "description": "d", "arcsCount": 1, "playerCount": 0, "loreId": null, "gameSystemId": null } + ], + "arcs": [ + { "id": 10, "name": "Hub Arc", "description": "d", "campaignId": 1, "order": 0, "type": "HUB", + "icon": null, "relatedPageIds": [], "illustrationImageIds": [] } + ], + "chapters": [ + { "id": 100, "name": "QA", "description": "d", "arcId": 10, "order": 0, + "prerequisitesJson": "[{\\"kind\\":\\"FLAG_SET\\",\\"flagName\\":\\"f\\"}]", + "relatedPageIds": [], "illustrationImageIds": [] }, + { "id": 101, "name": "QB", "description": "d", "arcId": 10, "order": 1, + "prerequisitesJson": "[{\\"kind\\":\\"QUEST_COMPLETED\\",\\"questId\\":\\"100\\"}]", + "relatedPageIds": [], "illustrationImageIds": [] } + ], + "playthroughs": [ + { "id": 50, "campaignId": 1, "name": "P", "description": null } + ], + "questProgressions": [ + { "id": 1, "playthroughId": 50, "chapterId": 100, "status": "IN_PROGRESS" } + ] + } + """; + byte[] zip = zipWithDataJson(dataJson); + + ImportResult result = importService.importZip(new ByteArrayInputStream(zip)); + + // Les deux chapitres HUB sont convertis en quêtes. + assertEquals(2, result.created().get("quests")); + QuestJpaEntity qa = questRepo.findAll().stream().filter(q -> "QA".equals(q.getName())).findFirst().orElseThrow(); + QuestJpaEntity qb = questRepo.findAll().stream().filter(q -> "QB".equals(q.getName())).findFirst().orElseThrow(); + + // QA : 1 prérequis (FlagSet) + un nœud CHAPTER vers le chapitre importé. + assertEquals(1, qa.getPrerequisites().size()); + assertEquals(1, qa.getNodes().size()); + assertEquals(NodeType.CHAPTER, qa.getNodes().get(0).nodeType()); + ChapterJpaEntity importedChapterQA = chapterRepo.findAll().stream() + .filter(c -> "QA".equals(c.getName())).findFirst().orElseThrow(); + assertEquals(String.valueOf(importedChapterQA.getId()), qa.getNodes().get(0).nodeId()); + + // QB : prérequis QuestCompleted remappé vers la QUÊTE QA (et non un chapitre). + Prerequisite p = qb.getPrerequisites().get(0); + assertInstanceOf(Prerequisite.QuestCompleted.class, p); + assertEquals(String.valueOf(qa.getId()), ((Prerequisite.QuestCompleted) p).questId()); + + // quest_progression pointe la QUÊTE QA (et non un chapitre). + assertEquals(1, questProgressionRepo.count()); + QuestProgressionJpaEntity prog = questProgressionRepo.findAll().get(0); + assertEquals(qa.getId(), prog.getQuestId()); + } + private static byte[] zipWithDataJson(String dataJson) throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (ZipOutputStream zip = new ZipOutputStream(baos)) { diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java index 44c5392..9a5c5eb 100644 --- a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java +++ b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java @@ -1,5 +1,6 @@ package com.loremind.infrastructure.transfer.foundry; +import com.loremind.domain.campaigncontext.SceneBattlemap; import com.loremind.domain.shared.template.TemplateField; import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.jpa.*; @@ -67,8 +68,8 @@ class FoundryExportServiceTest { .chapterId(quest.getId()).name("L'attaque du convoi").order(0) .playerNarration("Le convoi s'arrete...") .illustrationImageIds(List.of(String.valueOf(img.getId()))) - .battlemapMediaFileId(String.valueOf(media.getId())) - .battlemapDataFileId(String.valueOf(sidecar.getId())) + .battlemaps(List.of(new SceneBattlemap("Nuit", + String.valueOf(media.getId()), String.valueOf(sidecar.getId())))) .build()); npcRepo.save(NpcJpaEntity.builder() @@ -101,8 +102,13 @@ class FoundryExportServiceTest { assertEquals(String.valueOf(arc.getId()), data.quests().get(0).arcId()); assertEquals(String.valueOf(quest.getId()), data.scenes().get(0).questId()); - // Battlemap : refs vers les assets fichiers (prefixe "file-"). + // Battlemaps : refs vers les assets fichiers (prefixe "file-"), label preserve, + // et champ legacy `battlemap` (1re carte) maintenu pour les modules existants. FoundryBundle.Scene scene = data.scenes().get(0); + assertEquals(1, scene.battlemaps().size()); + assertEquals("Nuit", scene.battlemaps().get(0).label()); + assertEquals("file-" + media.getId(), scene.battlemaps().get(0).mediaAssetId()); + assertEquals("file-" + sidecar.getId(), scene.battlemaps().get(0).dataAssetId()); assertNotNull(scene.battlemap()); assertEquals("file-" + media.getId(), scene.battlemap().mediaAssetId()); assertEquals("file-" + sidecar.getId(), scene.battlemap().dataAssetId()); @@ -151,6 +157,58 @@ class FoundryExportServiceTest { assertEquals(3, bundle.manifest().counts().get("assets")); } + @Test + void buildBundle_mapsOnly_stripsJournalsNpcsTablesAndIllustrations() { + CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder() + .name("Cartes seules").description("d").arcsCount(1).build()); + ImageJpaEntity illus = imageRepo.save(ImageJpaEntity.builder() + .filename("ambiance.webp").contentType("image/webp").sizeBytes(100L) + .storageKey("images/ill.webp").build()); + StoredFileJpaEntity media = fileRepo.save(StoredFileJpaEntity.builder() + .filename("crypte.png").contentType("image/png").sizeBytes(2048L) + .storageKey("files/crypte.png").build()); + + ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder() + .campaignId(camp.getId()).name("Acte I").order(0) + .illustrationImageIds(List.of(String.valueOf(illus.getId()))).build()); + ChapterJpaEntity chap = chapterRepo.save(ChapterJpaEntity.builder() + .arcId(arc.getId()).name("La crypte").order(0).build()); + sceneRepo.save(SceneJpaEntity.builder() + .chapterId(chap.getId()).name("Salle des tombeaux").order(0) + .illustrationImageIds(List.of(String.valueOf(illus.getId()))) + .battlemaps(List.of(new SceneBattlemap("Nuit", String.valueOf(media.getId()), null))) + .build()); + npcRepo.save(NpcJpaEntity.builder() + .campaignId(camp.getId()).name("Le prêtre").order(0).build()); + RandomTableJpaEntity table = RandomTableJpaEntity.builder() + .campaignId(camp.getId()).name("Rencontres").diceFormula("1d6").order(0).build(); + tableRepo.save(table); + + FoundryExportService.BuiltBundle bundle = service.buildBundle( + String.valueOf(camp.getId()), "2026-07-03T00:00:00Z", + new FoundryExportService.ExportOptions(true, false, false)); + FoundryBundle.Data data = bundle.data(); + + // Périmètre : cartes+ennemis SEULEMENT — pas de PNJ, pas de tables, options posées. + assertTrue(data.npcs().isEmpty()); + assertTrue(data.randomTables().isEmpty()); + assertTrue(data.options().maps()); + assertFalse(data.options().journals()); + assertFalse(data.options().tables()); + + // L'ossature (arc/quête/scène) reste pour les dossiers, SANS illustrations. + assertEquals(1, data.arcs().size()); + assertTrue(data.arcs().get(0).illustrationAssetIds().isEmpty()); + FoundryBundle.Scene scene = data.scenes().get(0); + assertTrue(scene.illustrationAssetIds().isEmpty()); + assertEquals(1, scene.battlemaps().size()); + assertEquals("Nuit", scene.battlemaps().get(0).label()); + + // Un SEUL binaire embarqué : la battlemap (l'illustration n'est pas exportée). + assertEquals(1, bundle.binaries().size()); + assertTrue(data.assets().stream().allMatch(a -> a.kind().startsWith("battlemap"))); + } + @Test void buildBundle_unknownCampaign_throwsNoSuchElement() { assertThrows(java.util.NoSuchElementException.class, diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceRichTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceRichTest.java index 75ff8b4..358b6fa 100644 --- a/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceRichTest.java +++ b/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceRichTest.java @@ -1,5 +1,9 @@ package com.loremind.infrastructure.transfer.pdf; +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.domain.campaigncontext.SceneBattlemap; import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.images.ports.ImageStorage; import com.loremind.domain.shared.template.FieldType; @@ -41,6 +45,7 @@ class PdfExportServiceRichTest { @Autowired private ArcJpaRepository arcRepo; @Autowired private ChapterJpaRepository chapterRepo; @Autowired private SceneJpaRepository sceneRepo; + @Autowired private QuestJpaRepository questRepo; @Autowired private NpcJpaRepository npcRepo; @Autowired private EnemyJpaRepository enemyRepo; @Autowired private ImageJpaRepository imageRepo; @@ -112,14 +117,27 @@ class PdfExportServiceRichTest { .name("La porte").arcId(arc.getId()).order(0) .description("Franchir la porte.").playerObjectives("Entrer").narrativeStakes("Le temps presse") .gmNotes("Piège").build()); + // 2e chapitre : la campagne n'est PLUS "à plat" -> exerce le rendu HIÉRARCHIQUE (arc -> chapitre -> scènes). + chapterRepo.save(ChapterJpaEntity.builder() + .name("La fuite").arcId(arc.getId()).order(1).description("S'échapper.").build()); sceneRepo.save(SceneJpaEntity.builder() .name("L'embuscade").chapterId(chap.getId()).order(0) .location("Ruelle").timing("Nuit").atmosphere("Tendue").playerNarration("Des ombres bougent") .gmSecretNotes("3 bandits").choicesConsequences("Fuir ou combattre").combatDifficulty("Moyen") - .battlemapMediaFileId(String.valueOf(mapImg.getId())).build()); + .battlemaps(List.of(new SceneBattlemap("Nuit", String.valueOf(mapImg.getId()), null))).build()); sceneRepo.save(SceneJpaEntity.builder() .name("La poursuite").chapterId(chap.getId()).order(1) - .battlemapMediaFileId(String.valueOf(clip.getId())).build()); + .battlemaps(List.of(new SceneBattlemap("", String.valueOf(clip.getId()), null))).build()); + + // Quête (Niveau 1) : prérequis (flag) + nœud vers un chapitre + champs narratifs. + // Exerce la section « Quêtes » du PDF (renderPrerequisites / renderQuestNodes). + questRepo.save(QuestJpaEntity.builder() + .campaignId(camp.getId()).name("Sauver le marchand").order(0) + .description("Retrouver le marchand disparu.") + .prerequisites(List.of(new Prerequisite.FlagSet("porte_ouverte"))) + .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, String.valueOf(chap.getId()), 0))) + .playerObjectives("Le ramener vivant").narrativeStakes("Sa famille attend") + .gmNotes("Il est retenu par les bandits").build()); // PNJ avec portrait + champs de template (dont galerie d'image corrompue) ; rangé en dossier. npcRepo.save(NpcJpaEntity.builder() @@ -150,6 +168,26 @@ class PdfExportServiceRichTest { assertEquals("Campagne Riche & ", service.campaignName(String.valueOf(camp.getId()))); } + @Test + void exportsFlatCampaign_scenesWithoutArcChapterHeaders() { + // Mode plat : 1 arc d'un SEUL chapitre -> les scènes sont présentées à plat + // (exerce narrativeFlat ; pas d'en-têtes Arc/Chapitre « Quête »). + CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder() + .name("Campagne plate").description("d").arcsCount(1).build()); + ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder() + .name("Arc masqué").campaignId(camp.getId()).order(0).build()); + ChapterJpaEntity ch = chapterRepo.save(ChapterJpaEntity.builder() + .name("Chapitre 1").arcId(arc.getId()).order(0).build()); + sceneRepo.save(SceneJpaEntity.builder() + .name("Scène libre").chapterId(ch.getId()).order(0).playerNarration("Du texte.").build()); + + byte[] pdf = service.export(String.valueOf(camp.getId())); + + assertNotNull(pdf); + assertTrue(pdf.length > 1000, "le PDF plat doit avoir un contenu substantiel"); + assertEquals("%PDF-", new String(pdf, 0, 5, StandardCharsets.US_ASCII)); + } + @Test void export_unknownCampaign_throws() { assertThrows(java.util.NoSuchElementException.class, () -> service.export("999999999")); diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignFlagControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignFlagControllerTest.java index e18284c..f4f73b9 100644 --- a/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignFlagControllerTest.java +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignFlagControllerTest.java @@ -1,12 +1,10 @@ package com.loremind.infrastructure.web.controller; -import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Campaign; -import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Prerequisite; -import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.ports.CampaignRepository; -import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -24,8 +22,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. /** * Tests d'intégration de {@link CampaignFlagController}. * Unique endpoint : list (GET) qui déduplique les noms de faits (Prerequisite.FlagSet) - * référencés dans les prérequis des chapitres de la campagne. - * Fixtures : campaign -> arc -> chapters avec prérequis FlagSet. + * référencés dans les prérequis des QUÊTES de la campagne (Niveau 1). */ @SpringBootTest @AutoConfigureMockMvc @@ -34,23 +31,19 @@ class CampaignFlagControllerTest { @Autowired private MockMvc mockMvc; @Autowired private CampaignRepository campaignRepository; - @Autowired private ArcRepository arcRepository; - @Autowired private ChapterRepository chapterRepository; + @Autowired private QuestRepository questRepository; private String campaignId; - private String arcId; @BeforeEach void setUp() { - // Chaîne de fixtures : campaign -> arc campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); - arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build()).getId(); } @Test void list_returnsEmptyArray_whenNoFlagPrerequisites() throws Exception { - // Chapitre sans prérequis FlagSet => aucun fait référencé - chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build()); + // Quête sans prérequis FlagSet => aucun fait référencé + questRepository.save(Quest.builder().campaignId(campaignId).name("Q").order(0).build()); mockMvc.perform(get("/api/campaigns/{cid}/flags", campaignId)) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) @@ -59,13 +52,13 @@ class CampaignFlagControllerTest { @Test void list_returnsDeduplicatedSortedFlagNames() throws Exception { - // Deux chapitres référençant des FlagSet, avec un doublon "alpha" - chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch1").order(0) + // Deux quêtes référençant des FlagSet, avec un doublon "alpha" + questRepository.save(Quest.builder().campaignId(campaignId).name("Q1").order(0) .prerequisites(List.of( new Prerequisite.FlagSet("zeta"), new Prerequisite.FlagSet("alpha"))) .build()); - chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch2").order(1) + questRepository.save(Quest.builder().campaignId(campaignId).name("Q2").order(1) .prerequisites(List.of( new Prerequisite.FlagSet("alpha"), // doublon new Prerequisite.QuestCompleted("q-1"))) // ignoré (pas un FlagSet) @@ -82,7 +75,7 @@ class CampaignFlagControllerTest { @Test void list_returnsEmptyArray_forUnknownCampaign() throws Exception { - // Campagne sans arcs => liste vide (pas d'erreur) + // Campagne sans quêtes => liste vide (pas d'erreur) mockMvc.perform(get("/api/campaigns/{cid}/flags", "999999999")) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignTreeControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignTreeControllerTest.java new file mode 100644 index 0000000..e945ca6 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/CampaignTreeControllerTest.java @@ -0,0 +1,77 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Test d'intégration de l'endpoint AGRÉGÉ de l'arbre de campagne : structure complète + * (arcs → chapitres → scènes, quêtes, readiness) en une seule requête. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CampaignTreeControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private CampaignRepository campaignRepository; + @Autowired private ArcRepository arcRepository; + @Autowired private ChapterRepository chapterRepository; + @Autowired private SceneRepository sceneRepository; + @Autowired private QuestRepository questRepository; + + private String campaignId; + private String arcId; + private String chapterId; + + @BeforeEach + void setUp() { + campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); + arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("Arc") + .type(ArcType.HUB).order(0).build()).getId(); + chapterId = chapterRepository.save(Chapter.builder().arcId(arcId).name("Chap").order(0).build()).getId(); + sceneRepository.save(Scene.builder().chapterId(chapterId).name("Scène").order(0).build()); + questRepository.save(Quest.builder().campaignId(campaignId).arcId(arcId).name("Quête").order(0).build()); + } + + @Test + void tree_returnsFullAggregate_inOneCall() throws Exception { + mockMvc.perform(get("/api/campaigns/{id}/tree", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.arcs", hasSize(1))) + .andExpect(jsonPath("$.arcs[0].type").value("HUB")) + .andExpect(jsonPath("$.chaptersByArc." + arcId, hasSize(1))) + .andExpect(jsonPath("$.scenesByChapter." + chapterId, hasSize(1))) + .andExpect(jsonPath("$.scenesByChapter." + chapterId + "[0].name").value("Scène")) + .andExpect(jsonPath("$.quests", hasSize(1))) + .andExpect(jsonPath("$.quests[0].arcId").value(arcId)) + .andExpect(jsonPath("$.readiness.campaignId").value(campaignId)) + .andExpect(jsonPath("$.readiness.gaps").isArray()); + } + + @Test + void tree_unknownCampaign_returns404() throws Exception { + mockMvc.perform(get("/api/campaigns/999999/tree")) + .andExpect(status().isNotFound()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java index 3fe743e..78e1236 100644 --- a/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/ChapterControllerTest.java @@ -7,8 +7,6 @@ import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository; -import com.loremind.domain.playcontext.Playthrough; -import com.loremind.domain.playcontext.ports.PlaythroughRepository; import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -36,18 +34,13 @@ class ChapterControllerTest { @Autowired private CampaignRepository campaignRepository; @Autowired private ArcRepository arcRepository; @Autowired private ChapterRepository chapterRepository; - @Autowired private PlaythroughRepository playthroughRepository; private String arcId; - private String playthroughId; @BeforeEach void setUp() { String campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build()).getId(); - // playthroughId REEL (id numerique) : l'enrichissement du statut le parse. - playthroughId = playthroughRepository.save( - Playthrough.builder().campaignId(campaignId).name("Table").description("").build()).getId(); } @Test @@ -114,25 +107,6 @@ class ChapterControllerTest { .andExpect(status().isNoContent()); } - // getById avec ?playthroughId= : branche d'enrichissement du statut. - // Le playthrough est inexistant -> snapshot vide -> statut NOT_STARTED, mais la branche est couverte. - @Test - void getById_withPlaythroughId_enrichesStatus() throws Exception { - Chapter saved = chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build()); - mockMvc.perform(get("/api/chapters/{id}", saved.getId()).param("playthroughId", playthroughId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.progressionStatus").value("NOT_STARTED")); - } - - // getAll avec ?playthroughId= : branche d'enrichissement sur la liste. - @Test - void getAll_withPlaythroughId_enrichesStatus() throws Exception { - chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build()); - mockMvc.perform(get("/api/chapters").param("playthroughId", playthroughId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].progressionStatus").value("NOT_STARTED")); - } - // deletion-impact : chapitre existant -> 200 avec le compte de scènes (0 ici). @Test void deletionImpact_returns200_whenExists() throws Exception { diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java index 8bf2051..b9f0544 100644 --- a/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java @@ -38,7 +38,8 @@ class DataTransferControllerTest { new ContentExport.Manifest(2, "dev", "t", scope), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), - List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of()); + List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), List.of(), List.of()); } private ArgumentCaptor captureExportAfter(Runnable call) { diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/QuestControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/QuestControllerTest.java new file mode 100644 index 0000000..17809d6 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/QuestControllerTest.java @@ -0,0 +1,152 @@ +package com.loremind.infrastructure.web.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Quest; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.QuestRepository; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class QuestControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private CampaignRepository campaignRepository; + @Autowired private QuestRepository questRepository; + @Autowired private PlaythroughRepository playthroughRepository; + + private String campaignId; + private String playthroughId; + + @BeforeEach + void setUp() { + campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId(); + playthroughId = playthroughRepository.save( + Playthrough.builder().campaignId(campaignId).name("Table").description("").build()).getId(); + } + + private Quest seedQuest(String name, int order) { + return questRepository.save(Quest.builder().campaignId(campaignId).name(name).order(order).build()); + } + + @Test + void create_returns200_andUsesPathCampaignId() throws Exception { + QuestDTO dto = new QuestDTO(); + dto.setName("Q1"); + dto.setOrder(0); + mockMvc.perform(post("/api/campaigns/{campaignId}/quests", campaignId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Q1")) + .andExpect(jsonPath("$.campaignId").value(campaignId)); + } + + @Test + void getById_returns200() throws Exception { + Quest saved = seedQuest("Q", 0); + mockMvc.perform(get("/api/campaigns/{campaignId}/quests/{questId}", campaignId, saved.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Q")); + } + + @Test + void getById_returns404_whenMissing() throws Exception { + mockMvc.perform(get("/api/campaigns/{campaignId}/quests/{questId}", campaignId, "999999999")) + .andExpect(status().isNotFound()); + } + + @Test + void listByCampaign_returnsArray() throws Exception { + seedQuest("Q", 0); + mockMvc.perform(get("/api/campaigns/{campaignId}/quests", campaignId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].name").value("Q")); + } + + @Test + void update_returns200() throws Exception { + Quest saved = seedQuest("old", 0); + QuestDTO dto = new QuestDTO(); + dto.setName("new"); + dto.setOrder(0); + mockMvc.perform(put("/api/campaigns/{campaignId}/quests/{questId}", campaignId, saved.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("new")); + } + + @Test + void update_returns400_whenMissing() throws Exception { + QuestDTO dto = new QuestDTO(); + dto.setName("new"); + dto.setOrder(0); + mockMvc.perform(put("/api/campaigns/{campaignId}/quests/{questId}", campaignId, "999999999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isBadRequest()); + } + + @Test + void delete_returns204() throws Exception { + Quest saved = seedQuest("X", 0); + mockMvc.perform(delete("/api/campaigns/{campaignId}/quests/{questId}", campaignId, saved.getId())) + .andExpect(status().isNoContent()); + } + + // ?playthroughId= : branche d'enrichissement du statut (playthrough réel, snapshot vide -> NOT_STARTED). + @Test + void getById_withPlaythroughId_enrichesStatus() throws Exception { + Quest saved = seedQuest("Q", 0); + mockMvc.perform(get("/api/campaigns/{campaignId}/quests/{questId}", campaignId, saved.getId()) + .param("playthroughId", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.progressionStatus").value("NOT_STARTED")) + .andExpect(jsonPath("$.effectiveStatus").value("AVAILABLE")); + } + + @Test + void listByCampaign_withPlaythroughId_enrichesStatus() throws Exception { + seedQuest("Q", 0); + mockMvc.perform(get("/api/campaigns/{campaignId}/quests", campaignId) + .param("playthroughId", playthroughId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].progressionStatus").value("NOT_STARTED")); + } + + @Test + void reorder_returns204() throws Exception { + Quest q1 = seedQuest("Q1", 0); + Quest q2 = seedQuest("Q2", 1); + String body = objectMapper.writeValueAsString(Map.of("orderedIds", List.of(q2.getId(), q1.getId()))); + mockMvc.perform(put("/api/campaigns/{campaignId}/quests/reorder", campaignId) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNoContent()); + } +} diff --git a/web/angular.json b/web/angular.json index 32a08d1..69b284c 100644 --- a/web/angular.json +++ b/web/angular.json @@ -44,8 +44,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "6kb", - "maximumError": "10kb" + "maximumWarning": "10kb", + "maximumError": "16kb" } ] }, diff --git a/web/e2e/tests/campaign/arc-edit.spec.ts b/web/e2e/tests/campaign/arc-edit.spec.ts index 6453ff6..5db1ba3 100644 --- a/web/e2e/tests/campaign/arc-edit.spec.ts +++ b/web/e2e/tests/campaign/arc-edit.spec.ts @@ -45,9 +45,17 @@ test.describe('Arc edit', () => { await page.getByLabel(/Titre de l'arc/i).fill(newName); await page.getByLabel(/Synopsis de l'arc/i).fill(values.description); + + // Les champs narratifs sont dans des sections repliables fermées par défaut : + // on les ouvre avant de taper (un clic sur l'en-tête toggle la section). + await page.locator('app-expandable-section', { hasText: 'Thèmes et enjeux' }).click(); await page.getByLabel(/Thèmes principaux/i).fill(values.themes); await page.getByLabel(/Enjeux globaux/i).fill(values.stakes); + + await page.locator('app-expandable-section', { hasText: 'Notes du MJ' }).click(); await page.getByLabel(/Notes et planification du MJ/i).fill(values.gmNotes); + + await page.locator('app-expandable-section', { hasText: 'Récompenses et dénouement' }).click(); await page.getByLabel(/Récompenses et progression/i).fill(values.rewards); await page.getByLabel(/Dénouement prévu/i).fill(values.resolution); diff --git a/web/e2e/tests/campaign/chapter-edit.spec.ts b/web/e2e/tests/campaign/chapter-edit.spec.ts index 882c1ec..54eee7c 100644 --- a/web/e2e/tests/campaign/chapter-edit.spec.ts +++ b/web/e2e/tests/campaign/chapter-edit.spec.ts @@ -40,7 +40,13 @@ test.describe('Chapter edit', () => { await page.getByLabel(/Titre du chapitre/i).fill(newName); await page.getByLabel(/Synopsis du chapitre/i).fill(values.description); + + // Les champs narratifs sont dans des sections repliables fermées par défaut : + // on les ouvre avant de taper (un clic sur l'en-tête toggle la section). + await page.locator('app-expandable-section', { hasText: 'Notes du MJ' }).click(); await page.getByLabel(/Notes du Maître de Jeu/i).fill(values.gmNotes); + + await page.locator('app-expandable-section', { hasText: 'Objectifs et enjeux' }).click(); await page.getByLabel(/Objectifs des joueurs/i).fill(values.playerObjectives); await page.getByLabel(/Enjeux narratifs/i).fill(values.narrativeStakes); diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index f93cb81..3406006 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -27,6 +27,10 @@ export const routes: Routes = [ { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) }, + { path: 'campaigns/:campaignId/quests', loadComponent: () => import('./campaigns/quest/quest-list/quest-list.component').then(m => m.QuestListComponent) }, + { path: 'campaigns/:campaignId/quests/create', loadComponent: () => import('./campaigns/quest/quest-edit/quest-edit.component').then(m => m.QuestEditComponent) }, + { path: 'campaigns/:campaignId/quests/:questId/edit', loadComponent: () => import('./campaigns/quest/quest-edit/quest-edit.component').then(m => m.QuestEditComponent) }, + { path: 'campaigns/:campaignId/quests/:questId', loadComponent: () => import('./campaigns/quest/quest-view/quest-view.component').then(m => m.QuestViewComponent) }, { path: 'campaigns/:campaignId/notebooks', loadComponent: () => import('./campaigns/notebook/notebook-list/notebook-list.component').then(m => m.NotebookListComponent) }, { path: 'campaigns/:campaignId/notebooks/:notebookId', loadComponent: () => import('./campaigns/notebook/notebook-detail/notebook-detail.component').then(m => m.NotebookDetailComponent) }, { path: 'campaigns/:campaignId/item-catalogs', loadComponent: () => import('./campaigns/item-catalog/item-catalog-list/item-catalog-list.component').then(m => m.ItemCatalogListComponent) }, diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html index 2c08541..245d1a4 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html @@ -26,18 +26,15 @@
- -
- - - - {{ 'arcEdit.illustrationsHint' | translate }} -
+ +

{{ 'arcEdit.optionalHint' | translate }}

+ + + +
-
+ + + + + {{ 'arcEdit.illustrationsHint' | translate }} + + + + +
+
+ + +
+
+ + +
+
+
+ + +
- + + {{ 'arcEdit.gmNotesHint' | translate }} +
+
+ + + +
+ +
- +
-
- -
- - - {{ 'arcEdit.gmNotesHint' | translate }} -
- -
- - -
- -
- - -
+ @if (loreId) { -
- + {{ 'arcEdit.relatedPagesHint' | translate }} -
+ } @if (!loreId) { diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss index 2033914..098a607 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss @@ -61,6 +61,17 @@ gap: 1.5rem; } +// Bandeau « tout est optionnel sauf le titre » en tête du formulaire. +.optional-hint { + margin: 0; + padding: 0.6rem 0.85rem; + background: rgba(108, 99, 255, 0.08); + border: 1px solid rgba(108, 99, 255, 0.25); + border-radius: 8px; + color: #a5b4fc; + font-size: 0.85rem; +} + // Override local : dans ce header, le bouton Supprimer est poussé à droite // via margin-left auto (les boutons Annuler/Sauvegarder restent groupés à gauche). // Styles de base fournis globalement par @app/styles/_buttons.scss (.btn-danger). diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index 36a5a04..f12698b 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -20,8 +20,11 @@ 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 { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component'; import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; +import { EntityAssistPanelComponent } from '../../../shared/entity-assist-panel/entity-assist-panel.component'; +import { FieldProposal } from '../../../services/entity-assist.model'; /** * Écran de détail/modification d'un Arc. @@ -34,7 +37,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-arc-edit', - imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, TranslatePipe], + imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, ExpandableSectionComponent, EntityAssistPanelComponent, TranslatePipe], templateUrl: './arc-edit.component.html', styleUrls: ['./arc-edit.component.scss'] }) @@ -56,6 +59,15 @@ export class ArcEditComponent implements OnInit, OnDestroy { toggleChat(): void { this.chatOpen = !this.chatOpen; } + /** Applique au FORMULAIRE les champs étoffés retenus (Pilier A). Non destructif. */ + onAssistApplied(fields: FieldProposal[]): void { + const patch: Record = {}; + for (const f of fields) { + if (this.form.get(f.key)) patch[f.key] = f.proposedValue; + } + this.form.patchValue(patch); + } + form: FormGroup; campaignId = ''; arcId = ''; @@ -71,6 +83,20 @@ export class ArcEditComponent implements OnInit, OnDestroy { /** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */ illustrationImageIds: string[] = []; + // ─────────────── État « rempli » par section (pastille de l'en-tête) ─────────────── + // Seul le titre est requis ; ces getters signalent ce qui contient déjà du contenu. + get illustrationsFilled(): boolean { return this.illustrationImageIds.length > 0; } + get themesStakesFilled(): boolean { + const v = this.form.value; + return !!(v.themes || v.stakes); + } + get gmNotesFilled(): boolean { return !!this.form.value.gmNotes; } + get rewardsResolutionFilled(): boolean { + const v = this.form.value; + return !!(v.rewards || v.resolution); + } + get loreFilled(): boolean { return this.relatedPageIds.length > 0; } + constructor( private fb: FormBuilder, private route: ActivatedRoute, diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.html b/web/src/app/campaigns/arc/arc-view/arc-view.component.html index 17e7e08..3451c67 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.html +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.html @@ -29,52 +29,6 @@ } - - @if (arc.type === 'HUB') { -
-

🗺️ {{ 'arcView.hubQuestsTitle' | translate }}

- @if (hubQuests.length === 0) { -

- {{ 'arcView.hubQuestsEmpty' | translate }} -

- } - @if (hubQuests.length > 0) { -
- @for (q of hubQuests; track q.id) { - - } -
- } -
- }

📜 {{ 'arcView.synopsisTitle' | translate }}

@if (arc.description?.trim()) { @@ -83,6 +37,35 @@

{{ 'arcView.notProvided' | translate }}

}
+ + @if (arc.type === 'HUB') { +
+
+

🏳️ {{ 'arcView.questsTitle' | translate }}

+ +
+ @if (hubQuests.length > 0) { + + } @else { +

{{ 'arcView.questsEmpty' | translate }}

+ } +
+ }

{{ 'arcView.themesTitle' | translate }}

diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts index ce3c527..0b36288 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts @@ -4,9 +4,8 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { DataSyncService } from '../../../services/data-sync.service'; -import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular'; +import { LucideAngularModule, Pencil, Trash2, Plus } from 'lucide-angular'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { resolveCampaignIcon } from '../../campaign-icons'; import { CampaignService } from '../../../services/campaign.service'; @@ -16,7 +15,7 @@ import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Arc, Chapter, Prerequisite } from '../../../services/campaign.model'; +import { Arc, Quest } 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'; @@ -25,32 +24,28 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia /** * Écran de consultation d'un Arc narratif (lecture seule). * Route : /campaigns/:campaignId/arcs/:arcId - * Bouton "Modifier" → /campaigns/:campaignId/arcs/:arcId/edit + * + * Niveau 1 — cutover : la grille « quêtes du hub » (chapitres-as-quêtes) a été + * retirée. Les quêtes sont désormais une entité orthogonale, gérées dans la branche + * « Quêtes » de la campagne. Les chapitres restent navigables via l'arbre de la sidebar. */ @Component({ selector: 'app-arc-view', - imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe, CdkDropList, CdkDrag], + imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe], templateUrl: './arc-view.component.html', styleUrls: ['./arc-view.component.scss'] }) export class ArcViewComponent implements OnInit, OnDestroy { readonly Pencil = Pencil; readonly Trash2 = Trash2; - readonly AlertCircle = AlertCircle; + readonly Plus = Plus; 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 = {}; + /** Quêtes rattachées à cet arc (affichées si l'arc est un HUB). */ + hubQuests: Quest[] = []; /** ID du Lore associé à la campagne (null si pas d'univers lié). */ loreId: string | null = null; @@ -104,51 +99,18 @@ export class ArcViewComponent implements OnInit, OnDestroy { this.arc = arc; this.loreId = loreId; this.availablePages = pages; + // Les quêtes viennent déjà avec treeData (chargées par loadCampaignTreeData) — pas de fetch en plus. + this.hubQuests = (treeData.quests ?? []) + .filter(q => q.arcId === arc.id) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); 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, this.translate)); }); } - /** Construit un libellé lisible pour un prérequis (tooltip de verrouillage). */ - describePrerequisite(p: Prerequisite): string { - switch (p.kind) { - case 'QUEST_COMPLETED': - return this.translate.instant('arcView.prereqQuestCompleted', { name: this.allChaptersById[p.questId]?.name ?? '?' }); - case 'SESSION_REACHED': - return this.translate.instant('arcView.prereqSessionReached', { n: p.minSessionNumber }); - case 'FLAG_SET': - return this.translate.instant('arcView.prereqFlagSet', { flag: p.flagName }); - } - } - - /** Réordonne les quêtes (chapitres) de l'arc par glisser-déposer. */ - dropQuest(event: CdkDragDrop): void { - if (event.previousIndex === event.currentIndex) return; - moveItemInArray(this.hubQuests, event.previousIndex, event.currentIndex); - this.dataSync.persist( - this.campaignService.reorderChapters(this.arcId, this.hubQuests.map(q => q.id!)), - () => this.load()); - } - - openQuest(q: Chapter): void { - if (!q.id) return; - this.router.navigate([ - '/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', q.id - ]); + /** Crée une quête rattachée à CET arc HUB (arcId pré-rempli). */ + createQuest(): void { + this.router.navigate(['/campaigns', this.campaignId, 'quests', 'create'], { queryParams: { arcId: this.arcId } }); } titleOfRelated(pageId: string): string { @@ -202,9 +164,6 @@ export class ArcViewComponent implements OnInit, OnDestroy { } 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. + // Volontairement vide : la sidebar reste prise en charge par le composant suivant. } } diff --git a/web/src/app/campaigns/campaign-tree.helper.spec.ts b/web/src/app/campaigns/campaign-tree.helper.spec.ts index 21c9c51..77b3004 100644 --- a/web/src/app/campaigns/campaign-tree.helper.spec.ts +++ b/web/src/app/campaigns/campaign-tree.helper.spec.ts @@ -15,10 +15,10 @@ function data(partial: Partial = {}): CampaignTreeData { } describe('buildCampaignTree', () => { - it('produit les nœuds fixes (PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => { + it('produit les nœuds fixes (quêtes, PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => { const tree = buildCampaignTree('c1', data(), t); expect(tree.map((n) => n.id)).toEqual([ - 'npcs-root', 'enemies-root', 'random-tables-root', + 'quests-root', 'npcs-root', 'enemies-root', 'random-tables-root', 'notebooks-root', 'item-catalogs-root', 'import-pdf-root', ]); }); @@ -34,16 +34,16 @@ describe('buildCampaignTree', () => { expect(arcLabels).toEqual(['Intro', 'Voyage', 'Final']); }); - it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => { + it('imbrique chapitres et scènes', () => { const tree = buildCampaignTree('camp', data({ arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never, - chaptersByArc: { a1: [{ id: 'c1', name: 'Chap', prerequisites: [{}] }] } as never, + // 2 chapitres pour éviter le mode plat (Niveau 0), qui masque arc + chapitre + // quand il n'y a qu'un seul arc d'un seul chapitre. + chaptersByArc: { a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }] } as never, scenesByChapter: { c1: [{ id: 's1', name: 'Scene' }] } as never, }), t); const arc = tree.find((n) => n.id === 'arc-a1')!; - const chapter = arc.children![0]; - expect(chapter.id).toBe('chapter-c1'); - expect(chapter.meta).toBe('🔒'); // cadenas si prérequis + const chapter = arc.children!.find((c) => c.id === 'chapter-c1')!; expect(chapter.children![0].id).toBe('scene-s1'); }); @@ -73,13 +73,96 @@ describe('buildCampaignTree', () => { expect(folder.children![0].meta).toBe('Niv. 3'); }); - it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => { - const hub = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never }), t) - .find((n) => n.id === 'arc-a')!; - expect(hub.createActions![0].label).toBe('campaignTree.newQuest'); + it('arc LINÉAIRE = chapitres ; arc HUB = quêtes (bouton « nouvelle quête » uniquement)', () => { + const hub = buildCampaignTree('c', data({ + arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never, + quests: [{ id: 'q1', name: 'Enquête', arcId: 'a' }, { id: 'q2', name: 'Transverse', arcId: null }] as never, + }), t); + const hubArc = hub.find((n) => n.id === 'arc-a')!; + expect(hubArc.children!.some((c) => c.id === 'quest-q1')).toBe(true); // rattachée → enfant + expect(hubArc.children!.some((c) => c.id === 'quest-q2')).toBe(false); // transverse → pas ici + // HUB : on ne crée QUE des quêtes (pas de « nouveau chapitre »). + expect(hubArc.createActions!.map((a) => a.label)).toEqual(['campaignTree.newQuest']); + expect(hubArc.createActions![0].queryParams).toEqual({ arcId: 'a' }); - const linear = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never }), t) - .find((n) => n.id === 'arc-a')!; - expect(linear.createActions![0].label).toBe('campaignTree.newChapter'); + // LINEAR : uniquement des chapitres ; une quête même arcId=arc n'apparaît PAS sous l'arc. + const linear = buildCampaignTree('c', data({ + arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never, + quests: [{ id: 'q3', name: 'X', arcId: 'a' }] as never, + }), t); + const linArc = linear.find((n) => n.id === 'arc-a')!; + expect(linArc.children!.some((c) => c.id === 'quest-q3')).toBe(false); + expect(linArc.createActions!.map((a) => a.label)).toEqual(['campaignTree.newChapter']); + + // Le nœud « Quêtes » dédié conserve son action de création. + expect(hub.find((n) => n.id === 'quests-root')!.createActions![0].label).toBe('campaignTree.newQuest'); + }); + + it('pastilles : un manque de QUÊTE n\'allume PAS une scène/PNJ portant le même id (séquences par table)', () => { + // Chaque table a sa propre séquence IDENTITY : quête id 7 et scène id 7 coexistent. + const gaps = [{ + entityType: 'QUEST', entityId: '7', entityName: 'Q', ruleId: 'QUEST-001-NO-NODES', + message: 'm', severity: 'BLOCKING', arcId: null, chapterId: null, + }]; + const tree = buildCampaignTree('c', data({ + arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never, + chaptersByArc: { a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }] } as never, + scenesByChapter: { c1: [{ id: '7', name: 'Scène homonyme' }] } as never, + npcs: [{ id: '7', name: 'PNJ homonyme' }] as never, + quests: [{ id: '7', name: 'Q', arcId: null }] as never, + }), t, gaps as never); + const arc = tree.find((n) => n.id === 'arc-a1')!; + const scene = arc.children!.find((c) => c.id === 'chapter-c1')!.children!.find((s) => s.id === 'scene-7')!; + expect(scene.statusDot).toBeUndefined(); // pas de faux positif + expect(tree.find((n) => n.id === 'npcs-root')!.statusDot).toBeUndefined(); // idem PNJ + expect(tree.find((n) => n.id === 'quests-root')!.statusDot).toBe('blocking'); // le vrai coupable + }); + + it('arc HUB : une quête FUSIONNE avec son chapitre jumeau (un seul nœud, scènes dessous)', () => { + // Héritage V10 : la quête référence son chapitre jumeau via un nœud CHAPTER. + const tree = buildCampaignTree('c', data({ + arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never, + chaptersByArc: { a: [{ id: 'c1', name: 'Jumeau' }, { id: 'c2', name: 'Chapitre libre' }] } as never, + scenesByChapter: { c1: [{ id: 's1', name: 'Scène du jumeau' }] } as never, + quests: [{ id: 'q1', name: 'La quête', arcId: 'a', nodes: [{ nodeType: 'CHAPTER', nodeId: 'c1', order: 0 }] }] as never, + }), t); + const hubArc = tree.find((n) => n.id === 'arc-a')!; + // La quête absorbe le jumeau : pas de nœud chapter-c1, ses scènes vivent sous quest-q1. + expect(hubArc.children!.some((c) => c.id === 'chapter-c1')).toBe(false); + const questNode = hubArc.children!.find((c) => c.id === 'quest-q1')!; + expect(questNode.children!.some((c) => c.id === 'scene-s1')).toBe(true); + expect(questNode.dropParentId).toBe('c1'); // le jumeau reste le conteneur des scènes + // Le chapitre NON consommé reste affiché tel quel (contenu mixte/historique). + expect(hubArc.children!.some((c) => c.id === 'chapter-c2')).toBe(true); + }); + + it("l'arc SYSTEM (« Quêtes libres ») est masqué de la narration", () => { + const tree = buildCampaignTree('c', data({ + arcs: [ + { id: 'a1', name: 'Arc visible', type: 'LINEAR' }, + { id: 'sys', name: 'Quêtes libres', type: 'SYSTEM' }, + ] as never, + chaptersByArc: { + a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }], + sys: [{ id: 'cf', name: 'Conteneur' }], + } as never, + }), t); + expect(tree.some((n) => n.id === 'arc-sys')).toBe(false); // pas dans l'arbre + expect(tree.some((n) => n.id === 'arc-a1')).toBe(true); + }); + + it('quête LIBRE : fusionnée sous « Quêtes » avec les scènes de son conteneur SYSTEM et « + scène »', () => { + const tree = buildCampaignTree('c', data({ + arcs: [{ id: 'sys', name: 'Quêtes libres', type: 'SYSTEM' }] as never, + chaptersByArc: { sys: [{ id: 'cf', name: 'Libre', arcId: 'sys' }] } as never, + scenesByChapter: { cf: [{ id: 's1', name: 'Scène à la volée' }] } as never, + quests: [{ id: 'q1', name: 'Libre', arcId: null, nodes: [{ nodeType: 'CHAPTER', nodeId: 'cf', order: 0 }] }] as never, + }), t); + const questsRoot = tree.find((n) => n.id === 'quests-root')!; + const questNode = questsRoot.children!.find((c) => c.id === 'quest-q1')!; + expect(questNode.children!.some((c) => c.id === 'scene-s1')).toBe(true); + expect(questNode.dropParentId).toBe('cf'); // le conteneur reçoit les drops de scènes + // « + scène » pointe vers la création de scène DANS le conteneur (arc SYSTEM). + expect(questNode.createActions![0].route).toBe('/campaigns/c/arcs/sys/chapters/cf/scenes/create'); }); }); diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index 7f2fa37..fad34a4 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -1,17 +1,17 @@ -import { Observable, forkJoin, of } from 'rxjs'; -import { switchMap, map } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { map, catchError } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../services/campaign.service'; import { NpcService } from '../services/npc.service'; import { RandomTableService } from '../services/random-table.service'; import { EnemyService } from '../services/enemy.service'; -import { TreeItem, SecondarySidebarConfig, GlobalItem, ReorderKind } from '../services/layout.service'; +import { TreeItem, TreeCreateAction, SecondarySidebarConfig, GlobalItem, ReorderKind } from '../services/layout.service'; import { groupByFolder, byOrder } from '../shared/folder-grouping.util'; -import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model'; +import { Arc, Chapter, Scene, Campaign, Quest } from '../services/campaign.model'; import { Npc } from '../services/npc.model'; import { RandomTable } from '../services/random-table.model'; import { Enemy } from '../services/enemy.model'; -import { catchError } from 'rxjs/operators'; +import { CampaignReadinessAssessment, ReadinessGap, ReadinessSeverity, ReadinessEntityType } from '../services/readiness.model'; /** * Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes) @@ -28,71 +28,93 @@ export interface CampaignTreeData { npcs: Npc[]; randomTables: RandomTable[]; enemies: Enemy[]; + /** Manques de readiness (Pilier B) — alimentent les pastilles de l'arbre. Optionnel. */ + gaps?: ReadinessGap[]; + /** Quêtes de la campagne — pour afficher celles rattachées à un arc HUB. Optionnel. */ + quests?: Quest[]; + /** Bilan de readiness complet — évite un fetch séparé pour le panneau du hub. */ + readiness?: CampaignReadinessAssessment | null; } export function loadCampaignTreeData( service: CampaignService, campaignId: string, - npcService: NpcService, - // Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les - // tables aléatoires sont chargées et apparaissent dans la sidebar. - randomTableService?: RandomTableService, - // Optionnel (même principe) : si fourni, les ennemis sont chargés et le nœud - // « Ennemis » devient dépliable (dossiers → fiches) en plus du lien. - enemyService?: EnemyService + // Paramètres CONSERVÉS pour ne pas toucher les ~15 appelants, mais devenus inutiles : + // l'arbre complet (structure + PNJ + tables + ennemis + quêtes + readiness) arrive + // désormais en UNE requête via GET /api/campaigns/{id}/tree — la rafale de 15-20 + // appels HTTP par navigation était ce qui rendait la sidebar lente. + _npcService?: NpcService, + _randomTableService?: RandomTableService, + _enemyService?: EnemyService ): Observable { - // 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), - npcs: npcService.getByCampaign(campaignId), - randomTables: randomTableService - ? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[]))) - : of([] as RandomTable[]), - enemies: enemyService - ? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[]))) - : of([] as Enemy[]) - }).pipe( - switchMap(({ arcs, npcs, randomTables, enemies }) => { - if (arcs.length === 0) { - return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, npcs, randomTables, enemies }); - } - const chapterCalls = arcs.map(a => - service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters }))) - ); - return forkJoin(chapterCalls).pipe( - switchMap(chapterResults => { - const chaptersByArc: Record = {}; - const allChapters: Chapter[] = []; - chapterResults.forEach(r => { - chaptersByArc[r.arcId] = r.chapters; - allChapters.push(...r.chapters); - }); - - if (allChapters.length === 0) { - return of({ arcs, chaptersByArc, scenesByChapter: {}, npcs, randomTables, enemies }); - } - const sceneCalls = allChapters.map(c => - service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes }))) - ); - return forkJoin(sceneCalls).pipe( - map(sceneResults => { - const scenesByChapter: Record = {}; - sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; }); - return { arcs, chaptersByArc, scenesByChapter, npcs, randomTables, enemies }; - }) - ); - }) - ); - }) + return service.getTree(campaignId).pipe( + map(tree => ({ + arcs: tree.arcs, + chaptersByArc: tree.chaptersByArc ?? {}, + scenesByChapter: tree.scenesByChapter ?? {}, + npcs: tree.npcs ?? [], + randomTables: tree.randomTables ?? [], + enemies: tree.enemies ?? [], + quests: tree.quests ?? [], + gaps: tree.readiness?.gaps ?? [], + readiness: tree.readiness ?? null + } as CampaignTreeData)), + // Dégradation gracieuse : sidebar vide plutôt qu'écran cassé si le back est indisponible. + catchError(() => of({ + arcs: [], chaptersByArc: {}, scenesByChapter: {}, + npcs: [], randomTables: [], enemies: [], quests: [], gaps: [], readiness: null + } as CampaignTreeData)) ); } -export function buildCampaignTree(campaignId: string, data: CampaignTreeData, translate: TranslateService): TreeItem[] { +export function buildCampaignTree( + campaignId: string, + data: CampaignTreeData, + translate: TranslateService, + readinessGaps: ReadinessGap[] = [] +): TreeItem[] { // Tri par ORDRE manuel (réagencé par glisser-déposer, persisté en base ; `byOrder` // et `groupByFolder` sont mutualisés avec les vues cartes). Repli sur 0 sinon. + // --- Readiness (Pilier B — guidage) : pastille par nœud = pire sévérité de + // l'entité ET de ses descendants, avec au survol LE(S) message(s) réel(s) du manque + // (fini le tooltip générique). Purement dérivé des gaps (aucune persistance). + // Clé TYPE|ID obligatoire : chaque table a sa propre séquence IDENTITY (une quête + // id 6 et une scène id 6 coexistent) — sans le type, un manque de quête allumerait + // à tort la pastille d'une scène/PNJ portant le même numéro. + const sevRank = (s: ReadinessSeverity): number => (s === 'BLOCKING' ? 2 : s === 'RECOMMENDED' ? 1 : 0); + const gapsByKey = new Map(); + for (const g of readinessGaps) { + const key = `${g.entityType}|${g.entityId}`; + const list = gapsByKey.get(key) ?? []; + list.push(g); + gapsByKey.set(key, list); + } + const gapsOf = (type: ReadinessEntityType, id?: string | null): ReadinessGap[] => + (id ? (gapsByKey.get(`${type}|${id}`) ?? []) : []); + const quests: Quest[] = data.quests ?? []; + const questsByArc = (arcId: string): Quest[] => quests.filter(q => q.arcId === arcId); + const chapterGaps = (ch: Chapter): ReadinessGap[] => [ + ...gapsOf('CHAPTER', ch.id), + ...(data.scenesByChapter[ch.id!] ?? []).flatMap(s => gapsOf('SCENE', s.id)) + ]; + const arcGaps = (arc: Arc): ReadinessGap[] => [ + ...gapsOf('ARC', arc.id), + ...(data.chaptersByArc[arc.id!] ?? []).flatMap(chapterGaps), + ...(arc.type === 'HUB' ? questsByArc(arc.id!).flatMap(q => gapsOf('QUEST', q.id)) : []) + ]; + /** Pastille + tooltip : les pires manques en clair (2 max, puis « + N autres »). */ + const dotProps = (gaps: ReadinessGap[]): Pick => { + const worst = Math.max(0, ...gaps.map(g => sevRank(g.severity))); + if (worst < 1) return {}; + const sorted = [...gaps].sort((a, b) => sevRank(b.severity) - sevRank(a.severity)); + const shown = sorted.slice(0, 2).map(g => g.message); + if (sorted.length > shown.length) { + shown.push(translate.instant('readiness.dot.more', { n: sorted.length - shown.length })); + } + return { statusDot: worst >= 2 ? 'blocking' : 'recommended', statusDotTitle: shown.join('\n') }; + }; + /** * Nœuds-dossiers d'une collection ordonnable : un nœud dépliable par dossier * (+ un pseudo-dossier « Sans dossier »), via le regroupement partagé. Tous les @@ -125,12 +147,14 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr }); const npcChildren = folderChildren(sortedNpcs, 'npc', npcItem, 'npc'); + const npcGaps = sortedNpcs.flatMap(n => gapsOf('NPC', n.id)); const npcsNode: TreeItem = { id: 'npcs-root', label: translate.instant('campaignTree.npcs'), iconKey: 'c-drama', children: npcChildren, + ...dotProps(npcGaps), meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined, // Cliquer le LIBELLÉ ouvre la page de liste (vue d'ensemble par dossiers) ; // cliquer le CHEVRON déplie l'arbre dans la sidebar — les deux coexistent. @@ -149,6 +173,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr // --- Ennemis (bestiaire) : même structure que les PNJ — dossiers dépliables // dans la sidebar + libellé cliquable vers la page de liste. const sortedEnemies = [...(data.enemies ?? [])].sort(byOrder); + const enemyGaps = sortedEnemies.flatMap(e => gapsOf('ENEMY', e.id)); const enemyItem = (e: Enemy): TreeItem => ({ id: `enemy-${e.id}`, label: e.name, @@ -158,54 +183,105 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr }); const enemyChildren = folderChildren(sortedEnemies, 'enemy', enemyItem, 'enemy'); - const sortedArcs = [...data.arcs].sort(byOrder); + // L'arc SYSTEM (« Quêtes libres ») est de la plomberie : ses chapitres sont les + // conteneurs des quêtes libres, affichés sous le nœud « Quêtes » — jamais en narration. + const sortedArcs = [...data.arcs].sort(byOrder).filter(a => a.type !== 'SYSTEM'); const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => { const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byOrder); + const isHub = arc.type === 'HUB'; - const chapterItems: TreeItem[] = sortedChapters.map(ch => { - const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder); - - const sceneItems: TreeItem[] = sortedScenes.map(sc => ({ + const sceneItemsOf = (ch: Chapter): TreeItem[] => + [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder).map(sc => ({ id: `scene-${sc.id}`, label: sc.name, iconKey: sc.icon ?? undefined, route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`, - dragKind: 'scene', dragId: sc.id + dragKind: 'scene' as ReorderKind, dragId: sc.id, + ...dotProps(gapsOf('SCENE', sc.id)) })); - return { - id: `chapter-${ch.id}`, - label: ch.name, - iconKey: ch.icon ?? undefined, - // Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire). - meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined, - children: sceneItems, - dragKind: 'chapter', dragId: ch.id, dropKinds: ['scene'], dropParentId: ch.id, - route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`, - createActions: [{ - id: `new-scene-${ch.id}`, - label: translate.instant('campaignTree.newScene'), - route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`, + + const newSceneAction = (ch: Chapter) => ({ + id: `new-scene-${ch.id}`, + label: translate.instant('campaignTree.newScene'), + route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`, + actionIcon: 'plus' as const + }); + + const chapterNode = (ch: Chapter): TreeItem => ({ + id: `chapter-${ch.id}`, + label: ch.name, + iconKey: ch.icon ?? undefined, + children: sceneItemsOf(ch), + dragKind: 'chapter', dragId: ch.id, dropKinds: ['scene'], dropParentId: ch.id, + route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`, + ...dotProps(chapterGaps(ch)), + createActions: [newSceneAction(ch)] + }); + + let children: TreeItem[]; + if (isHub) { + // Arc HUB = quêtes. Une quête FUSIONNE avec son chapitre jumeau (héritage V10 : + // même contenu, la quête référence le chapitre en nœud) : UN SEUL nœud d'arbre — + // la quête — portant les scènes du jumeau. Évite les doublons quête/chapitre qui + // rendaient l'arbre illisible sur les campagnes migrées. + const attached = [...questsByArc(arc.id!)].sort(byOrder); + const consumed = new Set(); + const questNodes: TreeItem[] = attached.map(q => { + const twinIds = new Set((q.nodes ?? []) + .filter(n => n.nodeType === 'CHAPTER').map(n => n.nodeId)); + const twins = sortedChapters.filter(ch => twinIds.has(ch.id!)); + twins.forEach(ch => consumed.add(ch.id!)); + const questNodeGaps = [...gapsOf('QUEST', q.id), ...twins.flatMap(chapterGaps)]; + const container = twins[0]; + return { + id: `quest-${q.id}`, + label: q.name, + iconKey: q.icon ?? 'flag', + route: `/campaigns/${campaignId}/quests/${q.id}`, + ...dotProps(questNodeGaps), + children: twins.flatMap(sceneItemsOf), + // Le premier jumeau reste le conteneur des scènes (ajout + réordonnancement). + ...(container ? { + dropKinds: ['scene'] as ReorderKind[], + dropParentId: container.id, + createActions: [newSceneAction(container)] + } : {}) + }; + }); + // Chapitres non « consommés » par une quête (contenu mixte / historique) : affichés tels quels. + const plainChapters = sortedChapters.filter(ch => !consumed.has(ch.id!)).map(chapterNode); + children = [...questNodes, ...plainChapters]; + } else { + children = sortedChapters.map(chapterNode); + } + + // HUB → on ne crée QUE des quêtes ; LINÉAIRE → que des chapitres. + const arcCreateActions: TreeCreateAction[] = isHub + ? [{ + id: `new-quest-${arc.id}`, + label: translate.instant('campaignTree.newQuest'), + route: `/campaigns/${campaignId}/quests/create`, + queryParams: { arcId: arc.id! }, actionIcon: 'plus' }] - }; - }); + : [{ + id: `new-chapter-${arc.id}`, + label: translate.instant('campaignTree.newChapter'), + route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`, + actionIcon: 'plus' + }]; + return { id: `arc-${arc.id}`, label: arc.name, iconKey: arc.icon ?? undefined, - children: chapterItems, + children, dragKind: 'arc', dragId: arc.id, dropKinds: ['chapter'], dropParentId: arc.id, route: `/campaigns/${campaignId}/arcs/${arc.id}`, + ...dotProps(arcGaps(arc)), sectionHeaderBefore: idx === 0 ? translate.instant('campaignTree.sectionNarration') : undefined, - - createActions: [{ - id: `new-chapter-${arc.id}`, - // Dans un arc hub, un "chapitre" est présenté comme une "quête". - label: arc.type === 'HUB' ? translate.instant('campaignTree.newQuest') : translate.instant('campaignTree.newChapter'), - route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`, - actionIcon: 'plus' - }] + createActions: arcCreateActions }; }); @@ -258,6 +334,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr label: translate.instant('campaignTree.enemies'), iconKey: 'skull', children: enemyChildren, + ...dotProps(enemyGaps), meta: sortedEnemies.length ? String(sortedEnemies.length) : undefined, route: `/campaigns/${campaignId}/enemies`, createActions: [{ @@ -276,7 +353,110 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr route: `/campaigns/${campaignId}/import` }; - return [...arcNodes, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode]; + // Mode "plat" (Niveau 0) : une campagne avec un seul arc d'un seul chapitre n'a + // pas besoin de la hiérarchie arc > chapitre. On masque ces deux niveaux et on + // présente directement les scènes sous un nœud neutre "Scènes". Le drag & drop + // des scènes et l'ajout restent identiques (c'est le nœud chapitre, relabellisé + // et remonté à la racine). Dès qu'un 2e arc OU un 2e chapitre existe, la + // structure complète réapparaît automatiquement. + const onlyArc = sortedArcs.length === 1 ? sortedArcs[0] : null; + const onlyArcChapters = onlyArc ? (data.chaptersByArc[onlyArc.id!] ?? []) : []; + let narrationNodes: TreeItem[] = arcNodes; + // Le mode plat ne s'applique PAS à un arc HUB (qui présente des quêtes, pas juste des scènes). + if (onlyArc && onlyArc.type !== 'HUB' && onlyArcChapters.length === 1) { + const ch = onlyArcChapters[0]; + const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder); + const sceneItems: TreeItem[] = sortedScenes.map(sc => ({ + id: `scene-${sc.id}`, + label: sc.name, + iconKey: sc.icon ?? undefined, + route: `/campaigns/${campaignId}/arcs/${onlyArc.id}/chapters/${ch.id}/scenes/${sc.id}`, + dragKind: 'scene', dragId: sc.id, + ...dotProps(gapsOf('SCENE', sc.id)) + })); + narrationNodes = [{ + id: `chapter-${ch.id}`, + label: translate.instant('campaignTree.scenesGroup'), + iconKey: 'book-open', + children: sceneItems, + ...dotProps(chapterGaps(ch)), + // Réordonnancement des scènes inchangé : ce nœud EST le chapitre (caché). + dropKinds: ['scene'], dropParentId: ch.id, + // En mode plat le chapitre est masqué : le libellé « Scènes » ouvre la CARTE des + // scènes (graphe) — cohérent avec le label, et garde le graphe accessible — plutôt + // que la vue du chapitre caché (titrée « Chapitre 1 », sans liste de scènes : déroutant). + // Le chevron, lui, déplie la liste des scènes ci-dessous. + route: `/campaigns/${campaignId}/arcs/${onlyArc.id}/chapters/${ch.id}/graph`, + sectionHeaderBefore: translate.instant('campaignTree.sectionNarration'), + createActions: [{ + id: `new-scene-${ch.id}`, + label: translate.instant('campaignTree.newScene'), + route: `/campaigns/${campaignId}/arcs/${onlyArc.id}/chapters/${ch.id}/scenes/create`, + actionIcon: 'plus' + }] + }]; + } + + // Quêtes LIBRES (hors arc) : même fusion quête/conteneur que dans les hubs — le + // conteneur vit dans l'arc SYSTEM (chargé avec l'arbre), la quête s'affiche avec + // ses scènes dessous et son « + scène ». Recherche des conteneurs TOUS arcs confondus. + const chapterById = new Map( + Object.values(data.chaptersByArc).flat().map(ch => [ch.id!, ch])); + const questsFolderGaps: ReadinessGap[] = []; + const freeQuestNodes: TreeItem[] = quests.filter(q => !q.arcId).sort(byOrder).map(q => { + const twins = (q.nodes ?? []) + .filter(n => n.nodeType === 'CHAPTER') + .map(n => chapterById.get(n.nodeId)) + .filter((c): c is Chapter => !!c); + const questGaps = [...gapsOf('QUEST', q.id), ...twins.flatMap(chapterGaps)]; + questsFolderGaps.push(...questGaps); + const container = twins[0]; + const sceneItems: TreeItem[] = twins.flatMap(ch => + [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder).map(sc => ({ + id: `scene-${sc.id}`, + label: sc.name, + iconKey: sc.icon ?? undefined, + route: `/campaigns/${campaignId}/arcs/${ch.arcId}/chapters/${ch.id}/scenes/${sc.id}`, + dragKind: 'scene' as ReorderKind, dragId: sc.id, + ...dotProps(gapsOf('SCENE', sc.id)) + }))); + return { + id: `quest-${q.id}`, + label: q.name, + iconKey: q.icon ?? 'flag', + route: `/campaigns/${campaignId}/quests/${q.id}`, + ...dotProps(questGaps), + children: sceneItems, + ...(container ? { + dropKinds: ['scene'] as ReorderKind[], + dropParentId: container.id, + createActions: [{ + id: `new-scene-${container.id}`, + label: translate.instant('campaignTree.newScene'), + route: `/campaigns/${campaignId}/arcs/${container.arcId}/chapters/${container.id}/scenes/create`, + actionIcon: 'plus' as const + }] + } : {}) + } as TreeItem; + }); + + const questsNode: TreeItem = { + id: 'quests-root', + label: translate.instant('campaignTree.quests'), + iconKey: 'flag', + route: `/campaigns/${campaignId}/quests`, + children: freeQuestNodes, + meta: freeQuestNodes.length ? String(freeQuestNodes.length) : undefined, + ...dotProps(questsFolderGaps), + createActions: [{ + id: 'new-quest-node', + label: translate.instant('campaignTree.newQuest'), + route: `/campaigns/${campaignId}/quests/create`, + actionIcon: 'plus' + }] + }; + + return [...narrationNodes, questsNode, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode]; } /** @@ -294,7 +474,8 @@ export function buildCampaignSidebarConfig( allCampaigns: Campaign[], treeData: CampaignTreeData, campaignId: string, - translate: TranslateService + translate: TranslateService, + readinessGaps: ReadinessGap[] = [] ): SecondarySidebarConfig { const globalItems: GlobalItem[] = allCampaigns.map(c => ({ id: c.id!, name: c.name, route: `/campaigns/${c.id}` @@ -303,7 +484,9 @@ export function buildCampaignSidebarConfig( title: campaign.name, // Titre cliquable → accueil de la campagne (raccourci depuis n'importe quelle sous-page). titleRoute: `/campaigns/${campaignId}`, - items: buildCampaignTree(campaignId, treeData, translate), + // Priorité aux gaps passés explicitement (hub) ; sinon ceux embarqués dans treeData + // (chargés par loadCampaignTreeData) → pastilles présentes sur TOUTES les pages. + items: buildCampaignTree(campaignId, treeData, translate, readinessGaps.length ? readinessGaps : (treeData.gaps ?? [])), footerLabel: translate.instant('campaignTree.allCampaigns'), createActions: [ { id: 'create-arc', label: translate.instant('campaignTree.newArc'), variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` } diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index 1750b55..99b1b5a 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -54,6 +54,10 @@
} + + @if (!editing && campaign.id) { + + } @if (editing) {
@@ -187,13 +191,19 @@

{{ 'campaignDetail.arcsTitle' | translate }}

+ @if (showQuickAddScene) { + + }
- @if (arcs.length > 0) { + @if (arcs.length > 0 && !showQuickAddScene) {
@for (arc of arcs; track arc.id) { @@ -205,13 +215,23 @@ }
} + + @if (arcs.length > 0 && showQuickAddScene) { +
+

{{ 'campaignDetail.flatModeHint' | translate }}

+
+ } @if (arcs.length === 0) {

{{ 'campaignDetail.noArc' | translate }}

- +
} @@ -277,3 +297,10 @@ (chapter-edit > « Conditions de déblocage »). -->
} + + + diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss index b045b2d..01250c7 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss @@ -486,6 +486,19 @@ &:hover { background: #5b52e0; } } +// Action secondaire de l'état vide (« ou créer un arc ») — bouton texte discret. +.empty-alt-action { + background: none; + border: none; + padding: 0; + color: #8a7bc8; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + + &:hover { text-decoration: underline; } +} + // ─────────────── Sessions de jeu (Play Context) ─────────────── .sessions-grid { display: grid; diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts index 31780cd..f9a8f25 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts @@ -9,9 +9,9 @@ import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X } from 'lucide-angular'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Router, RouterLink } from '@angular/router'; -import { forkJoin, of } from 'rxjs'; +import { forkJoin, of, Observable } from 'rxjs'; import { catchError, switchMap, filter, map } from 'rxjs/operators'; -import { CampaignService } from '../../../services/campaign.service'; +import { CampaignService, FoundryExportOptions } from '../../../services/campaign.service'; import { LoreService } from '../../../services/lore.service'; import { GameSystemService } from '../../../services/game-system.service'; import { GameSystem } from '../../../services/game-system.model'; @@ -30,10 +30,13 @@ import { Lore } from '../../../services/lore.model'; import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; import { FolderGroup, groupByFolder, byOrder } from '../../../shared/folder-grouping.util'; +import { CampaignReadinessAssessment } from '../../../services/readiness.model'; +import { ReadinessPanelComponent } from '../../../shared/readiness-panel/readiness-panel.component'; +import { FoundryExportDialogComponent } from '../foundry-export-dialog/foundry-export-dialog.component'; @Component({ selector: 'app-campaign-detail', - imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag], + imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag, ReadinessPanelComponent, FoundryExportDialogComponent], templateUrl: './campaign-detail.component.html', styleUrls: ['./campaign-detail.component.scss'] }) @@ -57,6 +60,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { /** Export Foundry en cours (anti double-clic). */ exportingFoundry = false; + /** Modale de périmètre de l'export Foundry. */ + foundryDialogOpen = false; /** Export PDF en cours (anti double-clic). */ exportingPdf = false; @@ -100,6 +105,9 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { /** Parties (Playthroughs) de cette campagne. */ playthroughs: Playthrough[] = []; + /** Bilan de préparation (Pilier B — guidage) : alimente le panneau + les pastilles. */ + readiness: CampaignReadinessAssessment | null = null; + /** Mode édition inline. */ editing = false; editName = ''; @@ -139,7 +147,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { // on recharge les cartes pour rester cohérent sans rafraîchir la page. this.dataSync.onChange(this.destroyRef, () => { if (this.campaign?.id) { - this.campaignService.getArcs(this.campaign.id).subscribe(a => this.arcs = [...a].sort(byOrder)); + this.campaignService.getArcs(this.campaign.id).subscribe(a => + this.arcs = [...a].sort(byOrder).filter(x => x.type !== 'SYSTEM')); this.loadNpcs(this.campaign.id); // PNJ regroupés/ordonnés this.campaignSidebar.show(this.campaign.id); // recharge l'arbre aussi } @@ -193,11 +202,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.campaign = campaign; this.editing = false; this.playthroughs = playthroughs; + // Le bilan complet arrive avec l'arbre agrégé — plus de fetch séparé. + this.readiness = treeData.readiness ?? null; this.loadLinkedLore(campaign); this.loadLinkedGameSystem(campaign); this.loadNpcs(campaign.id!); this.loadSessions(campaign.id!); - this.arcs = [...treeData.arcs].sort(byOrder); + // L'arc SYSTEM (« Quêtes libres ») est de la plomberie : pas de carte d'arc pour lui. + this.arcs = [...treeData.arcs].sort(byOrder).filter(a => a.type !== 'SYSTEM'); this.chapterCountByArc = this.computeChapterCounts(treeData); this.showLayout(allCampaigns, treeData); this.pageTitleService.set(campaign.name); @@ -315,11 +327,71 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']); } - /** Télécharge le bundle Foundry de la campagne via un lien temporaire. */ + // ─────────────── Ajout rapide d'une scène (conteneur par défaut) ─────────────── + // Niveau 0 : on ne force plus à créer un arc puis un chapitre avant de poser une + // scène. Si la campagne n'a pas encore de structure, on provisionne un + // « Arc principal / Chapitre 1 » implicite (masqué dans l'arbre tant qu'il est + // seul), puis on ouvre la création de scène. Visible seulement quand la + // structure est simple (vide, ou un seul arc d'au plus un chapitre). + quickSceneInFlight = false; + + get showQuickAddScene(): boolean { + if (this.arcs.length === 0) return true; + if (this.arcs.length === 1) return (this.chapterCountByArc[this.arcs[0].id!] ?? 0) <= 1; + return false; + } + + quickAddScene(): void { + if (!this.campaign || this.quickSceneInFlight) return; + this.quickSceneInFlight = true; + const campaignId = this.campaign.id!; + this.ensureDefaultChapter(campaignId).subscribe({ + next: ({ arcId, chapterId }) => { + this.quickSceneInFlight = false; + this.router.navigate(['/campaigns', campaignId, 'arcs', arcId, 'chapters', chapterId, 'scenes', 'create']); + }, + error: () => { + this.quickSceneInFlight = false; + console.error('Erreur lors de la préparation de la scène rapide'); + } + }); + } + + /** + * Garantit l'existence d'un chapitre cible pour une scène : réutilise le premier + * arc / chapitre s'ils existent, sinon crée « Arc principal » puis « Chapitre 1 ». + */ + private ensureDefaultChapter(campaignId: string): Observable<{ arcId: string; chapterId: string }> { + const newChapter = (arcId: string) => this.campaignService.createChapter({ + name: this.translate.instant('campaignDetail.defaultChapterName'), + description: '', arcId, order: 1, icon: null + }).pipe(map(ch => ({ arcId, chapterId: ch.id! }))); + + const firstArc = this.arcs[0]; + if (!firstArc) { + return this.campaignService.createArc({ + name: this.translate.instant('campaignDetail.defaultArcName'), + description: '', campaignId, order: 1, type: 'LINEAR', icon: null + }).pipe(switchMap(arc => newChapter(arc.id!))); + } + return this.campaignService.getChapters(firstArc.id!).pipe( + switchMap(chs => chs.length + ? of({ arcId: firstArc.id!, chapterId: chs[0].id! }) + : newChapter(firstArc.id!)) + ); + } + + /** Ouvre la modale de choix du périmètre de l'export Foundry. */ exportFoundry(): void { + if (!this.campaign?.id || this.exportingFoundry) return; + this.foundryDialogOpen = true; + } + + /** Télécharge le bundle Foundry (périmètre choisi) via un lien temporaire. */ + onFoundryExportConfirmed(opts: FoundryExportOptions): void { if (!this.campaign?.id || this.exportingFoundry) return; this.exportingFoundry = true; - this.campaignService.exportFoundry(this.campaign.id).subscribe({ + this.campaignService.exportFoundry(this.campaign.id, opts).subscribe({ next: (blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -333,11 +405,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { // Révocation différée : libère l'URL sans annuler le téléchargement en cours. setTimeout(() => URL.revokeObjectURL(url), 1000); this.exportingFoundry = false; + this.foundryDialogOpen = false; }, error: (err) => { // Ne pas avaler l'erreur en silence : visible en console pour diagnostic. console.error('Échec de l\'export Foundry', err); this.exportingFoundry = false; + this.foundryDialogOpen = false; } }); } @@ -407,7 +481,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { } private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void { - this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!, this.translate)); + this.layoutService.show(buildCampaignSidebarConfig( + this.campaign!, allCampaigns, data, this.campaign!.id!, this.translate, this.readiness?.gaps ?? [])); } // ─────────────── Édition / suppression de la Campagne ─────────────── diff --git a/web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.html b/web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.html new file mode 100644 index 0000000..ea77664 --- /dev/null +++ b/web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.html @@ -0,0 +1,59 @@ +@if (open) { +
+ +
+} diff --git a/web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.scss b/web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.scss new file mode 100644 index 0000000..5802fc6 --- /dev/null +++ b/web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.scss @@ -0,0 +1,151 @@ +// Même squelette visuel que confirm-dialog (backdrop/modal/header/actions), +// avec en plus les cases à cocher du périmètre et les raccourcis. +.confirm-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.confirm-modal { + background: #111827; + border: 1px solid #1f2937; + border-radius: 16px; + width: 100%; + max-width: 520px; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; + + &.variant-info { border-top: 4px solid #6c63ff; } +} + +.confirm-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid #1f2937; + + h2 { + flex: 1; + color: white; + font-size: 1.1rem; + font-weight: 600; + margin: 0; + } +} + +.confirm-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + flex-shrink: 0; + background: rgba(108, 99, 255, 0.15); + color: #6c63ff; +} + +.btn-close { + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + padding: 0.25rem; + border-radius: 6px; + display: flex; + transition: color 0.2s; + + &:hover { color: white; } +} + +.confirm-body { + padding: 1.25rem 1.5rem; + color: #d1d5db; + font-size: 0.9rem; + line-height: 1.6; +} + +.confirm-message { margin: 0 0 0.75rem; } + +.fx-presets { + display: flex; + gap: 0.5rem; + margin-bottom: 0.9rem; + + button { + background: #1a1a2e; + border: 1px solid #2a2a3d; + border-radius: 999px; + color: #9ca3af; + padding: 0.3rem 0.8rem; + font-size: 0.8rem; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + + &:hover { color: #d1d5db; border-color: #6c63ff; } + } +} + +.fx-option { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.55rem 0.6rem; + border: 1px solid #1f2937; + border-radius: 8px; + cursor: pointer; + + & + & { margin-top: 0.5rem; } + + input { margin-top: 0.2rem; accent-color: #6c63ff; } + + span { display: flex; flex-direction: column; } + strong { color: white; font-size: 0.9rem; font-weight: 600; } + small { color: #9ca3af; font-size: 0.8rem; line-height: 1.4; } +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + background: #0d121d; + border-top: 1px solid #1f2937; +} + +.btn-secondary { + padding: 0.6rem 1.25rem; + background: #1f2937; + color: #d1d5db; + border: 1px solid #374151; + border-radius: 8px; + font-size: 0.875rem; + cursor: pointer; + transition: background 0.2s; + + &:hover { background: #374151; } + &:disabled { opacity: 0.6; cursor: default; } +} + +.btn-confirm { + padding: 0.6rem 1.25rem; + color: white; + border: none; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + background: #6c63ff; + + &:hover { background: #5b52e0; } + &:disabled { opacity: 0.6; cursor: default; } +} diff --git a/web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.ts b/web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.ts new file mode 100644 index 0000000..8068ea0 --- /dev/null +++ b/web/src/app/campaigns/campaign/foundry-export-dialog/foundry-export-dialog.component.ts @@ -0,0 +1,54 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { LucideAngularModule, Download, X } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; +import { FoundryExportOptions } from '../../../services/campaign.service'; + +/** + * Modale de choix du PÉRIMÈTRE de l'export Foundry : tout, ou seulement les + * cartes + ennemis (usage table de jeu), ou seulement les journaux… Trois cases + * indépendantes + raccourcis. Le parent déclenche le téléchargement au confirm. + */ +@Component({ + selector: 'app-foundry-export-dialog', + imports: [LucideAngularModule, TranslatePipe], + templateUrl: './foundry-export-dialog.component.html', + styleUrls: ['./foundry-export-dialog.component.scss'] +}) +export class FoundryExportDialogComponent { + readonly Download = Download; + readonly X = X; + + @Input() open = false; + @Input() exporting = false; + @Output() cancelled = new EventEmitter(); + @Output() confirmed = new EventEmitter(); + + maps = true; + journals = true; + tables = true; + + get nothingSelected(): boolean { return !this.maps && !this.journals && !this.tables; } + + /** Raccourci « usage table de jeu » : uniquement les cartes + ennemis. */ + presetMapsOnly(): void { + this.maps = true; + this.journals = false; + this.tables = false; + } + + presetAll(): void { + this.maps = true; + this.journals = true; + this.tables = true; + } + + onCancel(): void { + if (this.exporting) return; + this.cancelled.emit(); + } + + onConfirm(): void { + if (this.nothingSelected || this.exporting) return; + this.confirmed.emit({ maps: this.maps, journals: this.journals, tables: this.tables }); + } +} diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html index aee61b2..d082926 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html @@ -1,21 +1,21 @@
- +
@@ -25,7 +25,7 @@
@@ -37,7 +37,7 @@
diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts index c216ca2..aa0774b 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts @@ -32,8 +32,6 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { campaignId = ''; arcId = ''; arcName = ''; - /** Arc parent de type hub : un "chapitre" y est présenté comme une "quête". */ - isHub = false; private existingChapterCount = 0; constructor( @@ -67,7 +65,6 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { }).subscribe(({ campaign, allCampaigns, treeData }) => { const currentArc = treeData.arcs.find(a => a.id === this.arcId); this.arcName = currentArc?.name ?? ''; - this.isHub = currentArc?.type === 'HUB'; this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0; this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html index 142b470..100708e 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html @@ -26,36 +26,21 @@ - -
-

- {{ (parentArc?.type === 'HUB' ? 'chapterEdit.unlockTitleQuest' : 'chapterEdit.unlockTitleChapter') | translate }} -

- - {{ (parentArc?.type === 'HUB' ? 'chapterEdit.unlockHintQuest' : 'chapterEdit.unlockHintChapter') | translate }} - - - -
+ +

{{ 'chapterEdit.optionalHint' | translate }}

- -
- - - - {{ 'chapterEdit.illustrationsHint' | translate }} -
+ + + + +
-
- - - {{ 'chapterEdit.gmNotesHint' | translate }} -
+ + + + + {{ 'chapterEdit.illustrationsHint' | translate }} + -
+ +
- + + {{ 'chapterEdit.gmNotesHint' | translate }}
-
- - + + + + +
+
+ + +
+
+ + +
-
+
@if (loreId) { -
- + {{ 'chapterEdit.relatedPagesHint' | translate }} -
+ } @if (!loreId) { diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss index 433318b..ccfb53e 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss @@ -73,6 +73,17 @@ gap: 1.5rem; } +// Bandeau « tout est optionnel sauf le titre » en tête du formulaire. +.optional-hint { + margin: 0; + padding: 0.6rem 0.85rem; + background: rgba(108, 99, 255, 0.08); + border: 1px solid rgba(108, 99, 255, 0.25); + border-radius: 8px; + color: #a5b4fc; + font-size: 0.85rem; +} + // Override local : bouton Supprimer poussé à droite (voir _buttons.scss pour la base). .btn-danger { display: inline-flex; diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts index 2dcbf08..85e11c0 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts @@ -13,17 +13,19 @@ import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model'; +import { Chapter } 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 { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component'; import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; +import { EntityAssistPanelComponent } from '../../../shared/entity-assist-panel/entity-assist-panel.component'; +import { FieldProposal } from '../../../services/entity-assist.model'; +import { SceneDraftPanelComponent } from '../../../shared/scene-draft-panel/scene-draft-panel.component'; /** * Écran d'édition d'un Chapitre. Donnée de SCÉNARIO uniquement. @@ -41,7 +43,9 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, - PrerequisiteEditorComponent, + ExpandableSectionComponent, + EntityAssistPanelComponent, + SceneDraftPanelComponent, TranslatePipe ], templateUrl: './chapter-edit.component.html', @@ -58,28 +62,45 @@ export class ChapterEditComponent implements OnInit, OnDestroy { toggleChat(): void { this.chatOpen = !this.chatOpen; } + /** Applique au FORMULAIRE les champs étoffés retenus (Pilier A). Non destructif. */ + onAssistApplied(fields: FieldProposal[]): void { + const patch: Record = {}; + for (const f of fields) { + if (this.form.get(f.key)) patch[f.key] = f.proposedValue; + } + this.form.patchValue(patch); + } + + /** Après création de scènes par l'IA : on ouvre le graphe du chapitre pour les voir. */ + onScenesCreated(n: number): void { + if (n > 0) { + this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'graph']); + } + } + form: FormGroup; campaignId = ''; arcId = ''; chapterId = ''; chapter: Chapter | null = null; + /** `?assist=draft-scenes` (bouton « Corriger » du guidage) → panneau IA déployé d'office. */ + assistParam: string | null = null; + availablePages: Page[] = []; loreId: string | null = null; relatedPageIds: string[] = []; illustrationImageIds: 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; + // ─────────────── État « rempli » par section (pastille de l'en-tête) ─────────────── + // Seul le titre est requis ; ces getters signalent ce qui contient déjà du contenu. + get illustrationsFilled(): boolean { return this.illustrationImageIds.length > 0; } + get gmNotesFilled(): boolean { return !!this.form.value.gmNotes; } + get objectivesStakesFilled(): boolean { + const v = this.form.value; + return !!(v.playerObjectives || v.narrativeStakes); + } + get loreFilled(): boolean { return this.relatedPageIds.length > 0; } constructor( private fb: FormBuilder, @@ -93,7 +114,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy { private layoutService: LayoutService, private pageTitleService: PageTitleService, private confirmDialog: ConfirmDialogService, - private campaignFlagService: CampaignFlagService, private translate: TranslateService ) { this.form = this.fb.group({ @@ -121,6 +141,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy { this.campaignId = newCampaignId; this.arcId = newArcId; this.chapterId = newChapterId; + this.assistParam = this.route.snapshot.queryParamMap.get('assist'); this.loadAll(); } }); @@ -147,20 +168,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy { this.selectedIcon = chapter.icon ?? null; this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])]; - 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 ?? '', @@ -173,10 +180,6 @@ 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, { @@ -187,7 +190,6 @@ 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, icon: this.selectedIcon diff --git a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html index 909cdc7..15628d7 100644 --- a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html @@ -2,13 +2,19 @@ @if (scenes.length === 0) { @@ -20,24 +26,35 @@ @if (scenes.length > 0) {
+ (pointercancel)="onPointerUp($event)" + (wheel)="onWheel($event)"> + - + @for (edge of edges; track edge) { - - + + + @if (edge.label) { {{ node.name }} + rx="8" ry="8" [attr.class]="'node-box node-box--' + node.type" /> {{ node.displayName }} + + + + + {{ 'chapterGraph.rename' | translate }} + + + } + @if (linkingFromId) { + + } - - {{ 'chapterGraph.hint' | translate }} - + + + @if (editingNodeId) { + + } + + +
+ + + +
+ + + @if (selectedEdge; as edge) { +
+
+ @if (selectedEdgeEndpoints; as ep) { + {{ ep.from }} → {{ ep.to }} + } + +
+ + + +
+ + +
+
+ }
+ + + {{ 'chapterGraph.hint' | translate }} + }
diff --git a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.scss b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.scss index 7e98ccb..83d8604 100644 --- a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.scss +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.scss @@ -17,6 +17,12 @@ } } +.graph-actions { + display: flex; + gap: 0.5rem; + align-items: flex-start; +} + .graph-empty { padding: 2rem; text-align: center; @@ -27,47 +33,42 @@ } .graph-container { - // Fond legerement plus sombre que la couleur des noeuds : creuse l'image - // sans aller jusqu'au noir pur (qui « brulerait » par contraste). + position: relative; // ancre les contrôles + l'éditeur de lien background: #0d0d18; border: 1px solid #374151; border-radius: 12px; - padding: 20px; - overflow: auto; - display: flex; - justify-content: center; - flex-direction: column; - align-items: center; + height: 72vh; // caméra : hauteur fixe, le viewBox gère le contenu + overflow: hidden; } .graph-svg { display: block; - max-width: 100%; - // Empeche le browser de clipper le contenu qui depasserait le viewport SVG - // pendant un drag — le scroll du conteneur prend le relais. - overflow: visible; - // Évite que le navigateur intercepte le drag pour faire de la sélection texte - // ou du panning natif sur les nœuds. + width: 100%; + height: 100%; + cursor: grab; // le fond se déplace au glisser (pan) touch-action: none; user-select: none; + + &:active { cursor: grabbing; } } .node { cursor: grab; - // Ombre portee douce pour detacher chaque noeud du fond. Faible alpha pour - // rester subtil sur fond sombre, large diffusion pour rester organique. filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5)); .node-box { - // Indigo desature : reprend la palette accent (#6c63ff) en version assombrie - // pour donner du caractere aux noeuds sans saturer la vue. Bordure assortie - // un peu plus claire pour bien dessiner le contour. fill: #1f1d3a; stroke: #4f4a7a; stroke-width: 2; transition: fill 0.15s ease, stroke 0.15s ease; } + .node-box--LOCATION { fill: #14342a; stroke: #2f8f6b; } + .node-box--ENCOUNTER { fill: #3a1d1d; stroke: #b4554f; } + .node-box--NPC { fill: #1a2942; stroke: #4f7ab4; } + .node-box--EVENT { fill: #3a301a; stroke: #b48a4f; } + .node-box--REVELATION { fill: #2e1d3a; stroke: #8b5fb4; } + .node-label { font-size: 0.9rem; font-weight: 500; @@ -75,6 +76,28 @@ pointer-events: none; } + .node-connect { + fill: #6c63ff; + stroke: #0d0d18; + stroke-width: 1.5; + cursor: crosshair; + opacity: 0.35; + transition: opacity 0.15s ease; + } + &:hover .node-connect { opacity: 1; } + + // Poignée de renommage (coin haut-droit), révélée au survol du nœud. + .node-rename { + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; + + .node-rename-bg { fill: #2c2952; stroke: #8b80ff; stroke-width: 1.5; } + .node-rename-glyph { fill: #e5e7eb; font-size: 12px; pointer-events: none; } + &:hover .node-rename-bg { fill: #3a3568; } + } + &:hover .node-rename { opacity: 1; } + &:hover .node-box { fill: #2c2952; stroke: #8b80ff; @@ -90,13 +113,31 @@ } } +// L'arête visible ne capte pas le pointeur ; c'est la « zone de clic » qui le fait. +.edge-line { + pointer-events: none; + transition: stroke-width 0.1s ease; +} + +// Zone de clic large et invisible au-dessus de chaque arête (sélection facile). +.edge-hit { + stroke: transparent; + stroke-width: 16; + fill: none; + cursor: pointer; + pointer-events: stroke; +} + +.edge.selected .edge-line { + stroke-width: 3.5; + filter: drop-shadow(0 0 4px rgba(139, 128, 255, 0.7)); +} + .edge-label { font-size: 0.75rem; fill: #e5e7eb; font-style: italic; cursor: grab; - // Halo sombre autour du texte pour rester lisible quand un label passe - // par-dessus une arête ou un autre nœud. Aligne sur la couleur du fond. paint-order: stroke; stroke: #0d0d18; stroke-width: 4px; @@ -109,6 +150,158 @@ } } +.link-temp { + stroke: #8b80ff; + stroke-width: 2; + stroke-dasharray: 5 4; + pointer-events: none; +} + +// Champ de renommage inline, posé par-dessus le nœud édité. +.node-rename-input { + position: absolute; + box-sizing: border-box; + padding: 0 0.6rem; + background: #2c2952; + border: 2px solid #8b80ff; + border-radius: 8px; + color: #f3f4f6; + font-size: 0.9rem; + font-weight: 500; + text-align: center; + z-index: 5; + + &:focus { outline: none; } + &::placeholder { color: #9ca3af; } +} + +// ─────────────── Contrôles de caméra ─────────────── +.graph-controls { + position: absolute; + right: 12px; + bottom: 12px; + display: flex; + flex-direction: column; + gap: 6px; + + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + background: #1a1a2e; + border: 1px solid #374151; + border-radius: 8px; + color: #cbd5e1; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + + &:hover { background: #24243a; color: #fff; border-color: #6c63ff; } + } +} + +// ─────────────── Éditeur d'un lien sélectionné ─────────────── +.edge-editor { + position: absolute; + top: 12px; + right: 12px; + width: 260px; + background: #14141f; + border: 1px solid #3a3658; + border-radius: 10px; + padding: 0.7rem 0.8rem 0.8rem; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.edge-editor-head { + display: flex; + align-items: center; + gap: 0.5rem; + + .edge-editor-title { + flex: 1; + min-width: 0; + font-size: 0.82rem; + font-weight: 600; + color: #cbb9ff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .edge-editor-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: 1px solid #3a3658; + border-radius: 6px; + color: #9ca3af; + cursor: pointer; + &:hover { background: #24243a; color: #fff; } + } +} + +.edge-field { + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.76rem; + color: #9ca3af; + + input, select { + width: 100%; + box-sizing: border-box; + background: #1a1a2e; + border: 1px solid #2a2a3d; + color: #f3f4f6; + border-radius: 6px; + padding: 0.4rem 0.5rem; + font-size: 0.83rem; + + &:focus { outline: none; border-color: #6c63ff; } + } +} + +.edge-editor-actions { + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.15rem; + + button { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.4rem 0.6rem; + border-radius: 7px; + font-size: 0.8rem; + cursor: pointer; + border: 1px solid transparent; + } + + .btn-del { + background: #3a1d1d; + border-color: #b4554f; + color: #ffb4ae; + &:hover { background: #4a2424; color: #fff; } + } + + .btn-save { + background: #163326; + border-color: #2f6b46; + color: #a7f3d0; + &:hover { background: #1c4531; color: #fff; } + } +} + .graph-hint { display: block; margin-top: 1rem; diff --git a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts index 572ea81..1b21816 100644 --- a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts @@ -1,39 +1,50 @@ -import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core'; +import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin } from 'rxjs'; -import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; +import { LucideAngularModule, ArrowLeft, Plus, ZoomIn, ZoomOut, Maximize, Trash2, Check, X } from 'lucide-angular'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; import { EnemyService } from '../../../services/enemy.service'; -import { LayoutService, GlobalItem } from '../../../services/layout.service'; +import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Campaign, Chapter, Scene } from '../../../services/campaign.model'; -import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper'; +import { Chapter, Scene, SceneType, LinkType, SceneBranch, SceneCreate } from '../../../services/campaign.model'; +import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; -interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; } -interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; } +interface GraphNode { id: string; name: string; displayName: string; type: SceneType; x: number; y: number; } +interface GraphEdge { key: string; label: string; kind: LinkType; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; } /** * Vue graphique d'un chapitre : organigramme des scènes et branches narratives. * Layout custom (BFS par niveaux) en SVG — évite une dépendance lourde type ngx-graph. + * Caméra pan/zoom via viewBox ; les liens sont éditables/supprimables au clic. */ @Component({ selector: 'app-chapter-graph', - imports: [RouterModule, LucideAngularModule, TranslatePipe], + imports: [RouterModule, FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './chapter-graph.component.html', styleUrls: ['./chapter-graph.component.scss'] }) export class ChapterGraphComponent implements OnInit, OnDestroy { readonly ArrowLeft = ArrowLeft; + readonly Plus = Plus; + readonly ZoomIn = ZoomIn; + readonly ZoomOut = ZoomOut; + readonly Maximize = Maximize; + readonly Trash2 = Trash2; + readonly Check = Check; + readonly X = X; campaignId = ''; arcId = ''; chapterId = ''; chapter: Chapter | null = null; scenes: Scene[] = []; + /** Mode plat (1 arc, 1 chapitre) : la notion de chapitre est masquée dans cette vue. */ + isFlatMode = false; nodes: GraphNode[] = []; edges: GraphEdge[] = []; @@ -44,8 +55,32 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { readonly V_SPACING = 90; readonly MAX_LABEL_CHARS = 26; - svgWidth = 600; - svgHeight = 400; + // ─────────────── Caméra (viewBox) : pan + zoom ─────────────── + viewX = 0; + viewY = 0; + viewW = 600; + viewH = 400; + private readonly MIN_VIEW_W = 240; // zoom max (avant) + private readonly MAX_VIEW_W = 9000; // zoom max (arrière) + private hasFitted = false; + private panning = false; + private panStartX = 0; + private panStartY = 0; + private panStartViewX = 0; + private panStartViewY = 0; + + // ─────────────── Édition d'un lien sélectionné ─────────────── + selectedEdgeKey: string | null = null; + editEdgeLabel = ''; + editEdgeCondition = ''; + editEdgeKind: LinkType = 'EXIT'; + readonly linkKindOptions: LinkType[] = ['EXIT', 'CLUE', 'LEAD']; + + // ─────────────── Renommage inline d'une scène ─────────────── + editingNodeId: string | null = null; + editNodeName = ''; + // Boîte de l'input (px, relatifs au conteneur) calculée une fois à l'ouverture. + renameBox = { left: 0, top: 0, width: 0, height: 0 }; @ViewChild('svgEl') svgEl?: ElementRef; @@ -54,15 +89,17 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { // significatif a eu lieu (pour distinguer clic vs glisser). draggingId: string | null = null; draggingLabelKey: string | null = null; + // Création de lien par glisser (2c) : nœud source + position courante du pointeur. + linkingFromId: string | null = null; + linkPointerX = 0; + linkPointerY = 0; private dragOffsetX = 0; private dragOffsetY = 0; private dragMoved = false; private readonly DRAG_THRESHOLD = 4; // Decalage manuel applique a chaque label d'arete, indexe par cle stable - // (sourceId|targetId|branchIdx). Persiste a travers les recalculs d'aretes - // pour que le label suive son arete quand on deplace un noeud, tout en - // conservant le repositionnement manuel de l'utilisateur. + // (sourceId|targetId|branchIdx). Persiste a travers les recalculs d'aretes. private labelOffsets = new Map(); constructor( @@ -96,21 +133,29 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { }).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => { this.chapter = chapter; this.scenes = scenes; - this.pageTitleService.set(this.translate.instant('chapterGraph.title', { name: chapter.name })); + // Mode plat = campagne simple (1 arc, 1 chapitre) SANS compter la plomberie + // (arc SYSTEM des quêtes libres et ses conteneurs). + const narrativeArcs = treeData.arcs.filter(a => a.type !== 'SYSTEM'); + const narrativeArcIds = new Set(narrativeArcs.map(a => a.id)); + const allChapters = Object.entries(treeData.chaptersByArc) + .filter(([arcId]) => narrativeArcIds.has(arcId)) + .flatMap(([, chs]) => chs); + this.isFlatMode = narrativeArcs.length === 1 && allChapters.length === 1; + this.pageTitleService.set(this.isFlatMode + ? this.translate.instant('chapterGraph.titleFlat') + : this.translate.instant('chapterGraph.title', { name: chapter.name })); this.buildGraph(); - const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({ - id: c.id!, name: c.name, route: `/campaigns/${c.id}` - })); - this.layoutService.show({ - title: campaign.name, - items: buildCampaignTree(this.campaignId, treeData, this.translate), - footerLabel: this.translate.instant('chapterGraph.allCampaigns'), - createActions: [], - globalItems, - globalBackLabel: this.translate.instant('chapterGraph.allCampaigns'), - globalBackRoute: '/campaigns' - }); + // Recadrage automatique UNIQUEMENT au premier chargement : on préserve + // ensuite la caméra (pan/zoom) de l'utilisateur à travers les éditions. + if (!this.hasFitted && this.nodes.length > 0) { + this.fitView(); // provisoire (aspect de repli si le SVG n'est pas encore rendu) + setTimeout(() => this.fitView(), 0); // précis une fois le SVG dans le DOM + this.hasFitted = true; + } + + // Sidebar standard de campagne (titre cliquable + home, « + Nouvel arc », DnD). + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } @@ -121,7 +166,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { private buildGraph(): void { if (this.scenes.length === 0) { this.nodes = []; this.edges = []; - this.svgWidth = 600; this.svgHeight = 200; return; } @@ -166,35 +210,31 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { const levelWidth = count * this.NODE_WIDTH + (count - 1) * this.H_SPACING; const startX = (rowWidth - levelWidth) / 2; arr.forEach((s, i) => { + const autoX = startX + i * (this.NODE_WIDTH + this.H_SPACING); + const autoY = lvl * (this.NODE_HEIGHT + this.V_SPACING); nodes.push({ id: s.id!, name: s.name, displayName: this.truncate(s.name), - x: startX + i * (this.NODE_WIDTH + this.H_SPACING), - y: lvl * (this.NODE_HEIGHT + this.V_SPACING) + type: s.type ?? 'GENERIC', + // Position sauvegardée (Niveau 2) si présente, sinon layout auto (BFS). + x: s.graphX != null ? s.graphX : autoX, + y: s.graphY != null ? s.graphY : autoY }); }); } this.nodes = nodes; this.recomputeEdges(); - this.svgWidth = Math.max(rowWidth + 40, 600); - this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40; } - /** - * Recalcule la geometrie des aretes a partir des positions courantes des noeuds. - * Appele apres le layout initial et apres chaque deplacement manuel d'un noeud. - */ + /** Recalcule la geometrie des aretes a partir des positions courantes des noeuds. */ private recomputeEdges(): void { const nodeMap = new Map(this.nodes.map(n => [n.id, n])); const edges: GraphEdge[] = []; for (const scene of this.scenes) { const from = nodeMap.get(scene.id!); if (!from || !scene.branches) continue; - // On positionne chaque label a une fraction t differente de l'arete selon - // son index parmi les sorties du meme noeud source. Evite le chevauchement - // des labels au milieu quand plusieurs aretes convergent/divergent. const siblings = scene.branches.filter(b => nodeMap.has(b.targetSceneId)); const count = siblings.length; siblings.forEach((b, idx) => { @@ -203,13 +243,13 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { const y1 = from.y + this.NODE_HEIGHT; const x2 = to.x + this.NODE_WIDTH / 2; const y2 = to.y; - // t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes. const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3; const key = `${scene.id}|${b.targetSceneId}|${idx}`; const offset = this.labelOffsets.get(key) ?? { dx: 0, dy: 0 }; edges.push({ key, label: b.label, + kind: b.kind ?? 'EXIT', x1, y1, x2, y2, labelX: x1 + (x2 - x1) * t + offset.dx, labelY: y1 + (y2 - y1) * t - 4 + offset.dy @@ -219,10 +259,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { this.edges = edges; } - /** - * Convertit des coordonnees ecran (PointerEvent) en coordonnees SVG via la CTM - * inverse. Necessaire car le SVG peut etre redimensionne par max-width. - */ + /** Convertit des coordonnees ecran (PointerEvent) en coordonnees SVG via la CTM inverse. */ private toSvgCoords(evt: PointerEvent): { x: number; y: number } { const svg = this.svgEl?.nativeElement; if (!svg) return { x: evt.clientX, y: evt.clientY }; @@ -235,21 +272,122 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { return { x: local.x, y: local.y }; } - onPointerDown(evt: PointerEvent, node: GraphNode): void { - // Bouton gauche uniquement. + // ─────────────── Caméra : viewBox, pan, zoom ─────────────── + + get viewBox(): string { + return `${this.viewX} ${this.viewY} ${this.viewW} ${this.viewH}`; + } + + /** Recadre la caméra sur l'ensemble des nœuds (aspect du conteneur → pas de letterbox). */ + fitView(): void { + if (this.nodes.length === 0) { + this.viewX = 0; this.viewY = 0; this.viewW = 600; this.viewH = 400; + return; + } + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of this.nodes) { + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + this.NODE_WIDTH); + maxY = Math.max(maxY, n.y + this.NODE_HEIGHT); + } + const margin = 80; + minX -= margin; minY -= margin; maxX += margin; maxY += margin; + const w = maxX - minX, h = maxY - minY; + const rect = this.svgEl?.nativeElement.getBoundingClientRect(); + const aspect = rect && rect.height > 0 ? rect.width / rect.height : 16 / 9; + let vw = w, vh = h; + if (w / h > aspect) { vh = w / aspect; } else { vw = h * aspect; } + this.viewX = minX - (vw - w) / 2; + this.viewY = minY - (vh - h) / 2; + this.viewW = vw; + this.viewH = vh; + } + + /** Zoom autour du centre (boutons). factor < 1 = zoom avant. */ + zoomBy(factor: number): void { + const cx = this.viewX + this.viewW / 2; + const cy = this.viewY + this.viewH / 2; + const newW = Math.max(this.MIN_VIEW_W, Math.min(this.MAX_VIEW_W, this.viewW * factor)); + const ratio = newW / this.viewW; + this.viewW = newW; + this.viewH = this.viewH * ratio; + this.viewX = cx - this.viewW / 2; + this.viewY = cy - this.viewH / 2; + } + + /** Zoom à la molette, centré sur le curseur. */ + onWheel(evt: WheelEvent): void { + evt.preventDefault(); + const svg = this.svgEl?.nativeElement; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const mx = (evt.clientX - rect.left) / rect.width; + const my = (evt.clientY - rect.top) / rect.height; + const svgX = this.viewX + mx * this.viewW; + const svgY = this.viewY + my * this.viewH; + const factor = evt.deltaY > 0 ? 1.12 : 1 / 1.12; + const newW = Math.max(this.MIN_VIEW_W, Math.min(this.MAX_VIEW_W, this.viewW * factor)); + const ratio = newW / this.viewW; + this.viewW = newW; + this.viewH = this.viewH * ratio; + this.viewX = svgX - mx * this.viewW; + this.viewY = svgY - my * this.viewH; + } + + /** + * Réajuste la hauteur du viewBox à l'aspect du conteneur après un redimensionnement, + * en préservant le centre et l'échelle horizontale — évite le letterbox et le décalage + * du zoom-molette quand la fenêtre change de taille (la caméra reste où elle est). + */ + private applyAspect(): void { + const rect = this.svgEl?.nativeElement.getBoundingClientRect(); + if (!rect || rect.width === 0 || rect.height === 0) return; + const cy = this.viewY + this.viewH / 2; + const newH = this.viewW / (rect.width / rect.height); + this.viewH = newH; + this.viewY = cy - newH / 2; + } + + @HostListener('window:resize') + onWindowResize(): void { + this.applyAspect(); + } + + /** Pointerdown sur le FOND du SVG : désélectionne un lien et démarre le pan. */ + onSvgPointerDown(evt: PointerEvent): void { if (evt.button !== 0) return; evt.preventDefault(); - const { x, y } = this.toSvgCoords(evt); - this.draggingId = node.id; - this.dragOffsetX = x - node.x; - this.dragOffsetY = y - node.y; - this.dragMoved = false; - (evt.target as Element).setPointerCapture?.(evt.pointerId); + this.selectedEdgeKey = null; // clic dans le vide → ferme l'éditeur de lien + if (this.linkingFromId || this.draggingId || this.draggingLabelKey) return; + this.panning = true; + this.panStartX = evt.clientX; + this.panStartY = evt.clientY; + this.panStartViewX = this.viewX; + this.panStartViewY = this.viewY; + (evt.currentTarget as Element).setPointerCapture?.(evt.pointerId); } onPointerMove(evt: PointerEvent): void { + // Pan (fond) : delta écran → unités viewBox selon l'échelle courante. + if (this.panning) { + const rect = this.svgEl?.nativeElement.getBoundingClientRect(); + if (rect && rect.width > 0 && rect.height > 0) { + this.viewX = this.panStartViewX - (evt.clientX - this.panStartX) * (this.viewW / rect.width); + this.viewY = this.panStartViewY - (evt.clientY - this.panStartY) * (this.viewH / rect.height); + } + return; + } + const { x, y } = this.toSvgCoords(evt); + if (this.linkingFromId) { + this.linkPointerX = x; + this.linkPointerY = y; + return; + } + if (this.draggingLabelKey) { const edge = this.edges.find(e => e.key === this.draggingLabelKey); if (!edge) return; @@ -257,8 +395,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { const newY = y - this.dragOffsetY; if (!this.dragMoved && Math.hypot(newX - edge.labelX, newY - edge.labelY) < this.DRAG_THRESHOLD) return; this.dragMoved = true; - // Recalcule la position automatique courante puis stocke la difference, - // pour que l'offset reste valable meme apres deplacement d'un noeud. const auto = this.autoLabelPosition(edge.key); if (auto) { this.labelOffsets.set(edge.key, { dx: newX - auto.x, dy: newY - auto.y }); @@ -271,11 +407,10 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { if (!this.draggingId) return; const node = this.nodes.find(n => n.id === this.draggingId); if (!node) return; - // Empeche le noeud de partir en coordonnees negatives : sinon il sort - // du viewport SVG et se fait clipper par le navigateur (le SVG a - // overflow: hidden par defaut quand on lui donne width/height explicites). - const newX = Math.max(0, x - this.dragOffsetX); - const newY = Math.max(0, y - this.dragOffsetY); + // Avec une caméra viewBox, plus besoin de brider les coordonnées à 0 : + // le pan/zoom permet d'atteindre n'importe quelle zone. + const newX = x - this.dragOffsetX; + const newY = y - this.dragOffsetY; if (!this.dragMoved) { const dx = newX - node.x; const dy = newY - node.y; @@ -285,13 +420,8 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { node.x = newX; node.y = newY; this.recomputeEdges(); - this.fitSvgToNodes(); } - /** - * Recalcule la position "auto" (sans offset manuel) du label d'une arete - * a partir de sa cle. Utilise pour deriver le delta a stocker pendant le drag. - */ private autoLabelPosition(key: string): { x: number; y: number } | null { const [sourceId, targetId, idxStr] = key.split('|'); const idx = Number(idxStr); @@ -311,10 +441,20 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t - 4 }; } + onPointerDown(evt: PointerEvent, node: GraphNode): void { + if (evt.button !== 0) return; + evt.stopPropagation(); // évite de démarrer un pan du fond + evt.preventDefault(); + const { x, y } = this.toSvgCoords(evt); + this.draggingId = node.id; + this.dragOffsetX = x - node.x; + this.dragOffsetY = y - node.y; + this.dragMoved = false; + (evt.target as Element).setPointerCapture?.(evt.pointerId); + } + onLabelPointerDown(evt: PointerEvent, edge: GraphEdge): void { if (evt.button !== 0) return; - // Empeche l'event de remonter au ou au svg, sinon on - // declencherait aussi un drag de noeud. evt.stopPropagation(); evt.preventDefault(); const { x, y } = this.toSvgCoords(evt); @@ -325,24 +465,22 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { (evt.target as Element).setPointerCapture?.(evt.pointerId); } - /** - * Agrandit le SVG si un noeud s'approche du bord droit ou bas, pour eviter - * que le contenu deplace soit rogne. On ne reduit jamais en-dessous de la - * taille initiale du layout pour rester stable visuellement. - */ - private fitSvgToNodes(): void { - const margin = 40; - let maxX = 600; - let maxY = 200; - for (const n of this.nodes) { - if (n.x + this.NODE_WIDTH + margin > maxX) maxX = n.x + this.NODE_WIDTH + margin; - if (n.y + this.NODE_HEIGHT + margin > maxY) maxY = n.y + this.NODE_HEIGHT + margin; - } - if (maxX > this.svgWidth) this.svgWidth = maxX; - if (maxY > this.svgHeight) this.svgHeight = maxY; - } - onPointerUp(evt: PointerEvent): void { + if (this.panning) { + this.panning = false; + // Le pan capture le pointeur sur le SVG (currentTarget), pas sur une cible enfant. + (evt.currentTarget as Element).releasePointerCapture?.(evt.pointerId); + return; + } + if (this.linkingFromId) { + const { x, y } = this.toSvgCoords(evt); + const target = this.nodeAt(x, y); + const fromId = this.linkingFromId; + this.linkingFromId = null; + (evt.target as Element).releasePointerCapture?.(evt.pointerId); + if (target && target.id !== fromId) this.createLink(fromId, target.id); + return; + } if (this.draggingLabelKey) { this.draggingLabelKey = null; this.dragMoved = false; @@ -355,8 +493,19 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { this.draggingId = null; this.dragMoved = false; (evt.target as Element).releasePointerCapture?.(evt.pointerId); - // Si le pointeur n'a pas reellement bouge, on traite comme un clic d'ouverture. - if (!moved) this.openScene(id); + // Pointeur immobile → clic d'ouverture ; sinon on persiste la position (Niveau 2). + if (!moved) { this.openScene(id); return; } + this.persistNodePosition(id); + } + + private persistNodePosition(nodeId: string): void { + const node = this.nodes.find(n => n.id === nodeId); + const scene = this.scenes.find(s => s.id === nodeId); + if (!node || !scene) return; + scene.graphX = node.x; + scene.graphY = node.y; + this.campaignService.updateScene(nodeId, { ...scene, order: scene.order ?? 0 }) + .subscribe({ error: () => {} }); } private truncate(text: string): string { @@ -365,18 +514,203 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { : text; } + /** Couleur d'une arête selon son type de lien (Niveau 2). */ + edgeColor(kind: LinkType): string { + switch (kind) { + case 'CLUE': return '#f0b429'; // indice — ambre + case 'LEAD': return '#34d399'; // piste — vert + default: return '#b8c0cc'; // sortie — gris neutre (historique) + } + } + openScene(sceneId: string): void { this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', sceneId]); } back(): void { + if (this.isFlatMode) { + this.router.navigate(['/campaigns', this.campaignId]); + return; + } this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]); } + // ─────────────── Édition dans le graphe (Niveau 2) ─────────────── + + /** Crée une nouvelle scène dans ce chapitre et recharge la carte. */ + addScene(): void { + const payload: SceneCreate = { + name: this.translate.instant('chapterGraph.newSceneName'), + chapterId: this.chapterId, + order: this.scenes.length + }; + this.campaignService.createScene(payload).subscribe({ + next: () => this.load(), + error: () => {} + }); + } + + /** + * Démarre le renommage inline d'une scène : positionne un champ HTML par-dessus le + * nœud (conversion coords SVG → écran, figée le temps de l'édition) et le focus. + */ + startRenameNode(evt: PointerEvent, node: GraphNode): void { + if (evt.button !== 0) return; + evt.stopPropagation(); + evt.preventDefault(); + const svg = this.svgEl?.nativeElement; + const container = svg?.parentElement; + const ctm = svg?.getScreenCTM(); + if (svg && container && ctm) { + const p1 = svg.createSVGPoint(); p1.x = node.x; p1.y = node.y; + const p2 = svg.createSVGPoint(); p2.x = node.x + this.NODE_WIDTH; p2.y = node.y + this.NODE_HEIGHT; + const s1 = p1.matrixTransform(ctm); + const s2 = p2.matrixTransform(ctm); + const crect = container.getBoundingClientRect(); + this.renameBox = { left: s1.x - crect.left, top: s1.y - crect.top, width: s2.x - s1.x, height: s2.y - s1.y }; + } + this.editingNodeId = node.id; + this.editNodeName = node.name; + setTimeout(() => { + const el = document.getElementById('node-rename-input') as HTMLInputElement | null; + el?.focus(); + el?.select(); + }, 0); + } + + /** Valide le renommage : met à jour l'affichage localement et persiste (name seul). */ + saveRename(): void { + const id = this.editingNodeId; + if (!id) return; + this.editingNodeId = null; // avant tout : évite un double-save via le (blur) + const name = this.editNodeName.trim(); + const node = this.nodes.find(n => n.id === id); + const scene = this.scenes.find(s => s.id === id); + if (!name || !node || !scene || name === scene.name) return; + node.name = name; + node.displayName = this.truncate(name); + scene.name = name; + this.campaignService.updateScene(id, { ...scene, order: scene.order ?? 0 }).subscribe({ error: () => {} }); + } + + cancelRename(): void { + this.editingNodeId = null; + } + + /** Début d'un lien tiré depuis la pastille d'un nœud (sans déclencher son drag). */ + onConnectStart(evt: PointerEvent, node: GraphNode): void { + if (evt.button !== 0) return; + evt.stopPropagation(); + evt.preventDefault(); + const { x, y } = this.toSvgCoords(evt); + this.linkingFromId = node.id; + this.linkPointerX = x; + this.linkPointerY = y; + (evt.target as Element).setPointerCapture?.(evt.pointerId); + } + + linkSourceX(): number { + const n = this.nodes.find(x => x.id === this.linkingFromId); + return n ? n.x + this.NODE_WIDTH / 2 : 0; + } + linkSourceY(): number { + const n = this.nodes.find(x => x.id === this.linkingFromId); + return n ? n.y + this.NODE_HEIGHT : 0; + } + + /** Nœud dont la boîte contient le point (coords SVG), ou null. */ + private nodeAt(x: number, y: number): GraphNode | null { + return this.nodes.find(n => + x >= n.x && x <= n.x + this.NODE_WIDTH && + y >= n.y && y <= n.y + this.NODE_HEIGHT) ?? null; + } + + /** Crée une branche (SORTIE) de fromId vers toId si pas déjà liée, puis sauvegarde. */ + private createLink(fromId: string, toId: string): void { + const scene = this.scenes.find(s => s.id === fromId); + if (!scene) return; + const existing = scene.branches ?? []; + if (existing.some(b => b.targetSceneId === toId)) return; + const next: SceneBranch[] = [...existing, { label: '', targetSceneId: toId, condition: '', kind: 'EXIT' }]; + this.campaignService.updateScene(fromId, { ...scene, order: scene.order ?? 0, branches: next }) + .subscribe({ next: () => this.load(), error: () => {} }); + } + + // ─────────────── Sélection / édition / suppression d'un lien ─────────────── + + get selectedEdge(): GraphEdge | null { + return this.edges.find(e => e.key === this.selectedEdgeKey) ?? null; + } + + /** Noms des extrémités du lien sélectionné (pour l'en-tête de l'éditeur). */ + get selectedEdgeEndpoints(): { from: string; to: string } | null { + if (!this.selectedEdgeKey) return null; + const [sourceId, targetId] = this.selectedEdgeKey.split('|'); + const from = this.nodes.find(n => n.id === sourceId)?.name ?? '?'; + const to = this.nodes.find(n => n.id === targetId)?.name ?? '?'; + return { from, to }; + } + + /** + * Localise la branche EXACTE désignée par une clé d'arête (`sourceId|targetId|idx`). + * L'idx est la position parmi les branches dont la cible est un nœud affiché — ce qui + * cible le bon lien même si deux branches pointent la même scène (data legacy). + */ + private branchForKey(key: string): { scene: Scene; branchIndex: number } | null { + const [sourceId, , idxStr] = key.split('|'); + const idx = Number(idxStr); + const scene = this.scenes.find(s => s.id === sourceId); + if (!scene?.branches) return null; + const siblings = scene.branches.filter(b => this.nodes.some(n => n.id === b.targetSceneId)); + const branch = siblings[idx]; + if (!branch) return null; + const branchIndex = scene.branches.indexOf(branch); + return branchIndex >= 0 ? { scene, branchIndex } : null; + } + + /** Sélectionne un lien au clic et pré-remplit l'éditeur depuis sa branche. */ + onEdgeSelect(evt: PointerEvent, edge: GraphEdge): void { + if (evt.button !== 0) return; + evt.stopPropagation(); + evt.preventDefault(); + this.selectedEdgeKey = edge.key; + const found = this.branchForKey(edge.key); + const branch = found ? found.scene.branches![found.branchIndex] : undefined; + this.editEdgeLabel = branch?.label ?? ''; + this.editEdgeCondition = branch?.condition ?? ''; + this.editEdgeKind = branch?.kind ?? 'EXIT'; + } + + closeEdgeEditor(): void { + this.selectedEdgeKey = null; + } + + /** Enregistre le libellé / la condition / le type du lien sélectionné. */ + saveEdge(): void { + if (!this.selectedEdgeKey) return; + const found = this.branchForKey(this.selectedEdgeKey); + if (!found) return; + const { scene, branchIndex } = found; + const branches = (scene.branches ?? []).map((b, i) => + i === branchIndex + ? { ...b, label: this.editEdgeLabel.trim(), condition: this.editEdgeCondition.trim(), kind: this.editEdgeKind } + : b); + this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches }) + .subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} }); + } + + /** Supprime (sépare) le lien sélectionné. */ + deleteEdge(): void { + if (!this.selectedEdgeKey) return; + const found = this.branchForKey(this.selectedEdgeKey); + if (!found) return; + const { scene, branchIndex } = found; + const branches = (scene.branches ?? []).filter((_, i) => i !== branchIndex); + this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches }) + .subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} }); + } + 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. + // Volontairement vide : la sidebar reste prise en charge par le composant suivant. } } diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html index 3336747..bd2d80a 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html @@ -9,14 +9,7 @@ {{ chapter.name }}

- {{ (parentArc?.type === 'HUB' ? 'chapterView.questHub' : 'chapterView.chapter') | translate }} - @if ((chapter.prerequisites?.length ?? 0) > 0) { - - - {{ 'chapterView.conditional' | translate }} - - } + {{ 'chapterView.chapter' | translate }}

@@ -35,29 +28,6 @@
- - @if (parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0) { -
-

🔒 {{ 'chapterView.unlockConditions' | translate }}

- @if ((chapter.prerequisites?.length ?? 0) > 0) { -
    - @for (p of chapter.prerequisites; track p) { -
  • {{ describePrerequisite(p) }}
  • - } -
- - {{ (parentArc?.type === 'HUB' ? 'chapterView.unlockHintQuest' : 'chapterView.unlockHintChapter') | translate }} - - } @else { -

- {{ 'chapterView.noConditionQuest' | translate }} -

-

-

- } -
- } @if ((chapter.illustrationImageIds?.length ?? 0) > 0) {
diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts index 335d926..5f4a48c 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts @@ -3,7 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angular'; +import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { resolveCampaignIcon } from '../../campaign-icons'; import { CampaignService } from '../../../services/campaign.service'; @@ -13,7 +13,7 @@ import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model'; +import { Chapter } 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'; @@ -33,18 +33,15 @@ export class ChapterViewComponent implements OnInit, OnDestroy { readonly Pencil = Pencil; readonly Network = Network; readonly Trash2 = Trash2; - readonly Lock = Lock; readonly resolveCampaignIcon = resolveCampaignIcon; campaignId = ''; arcId = ''; chapterId = ''; chapter: Chapter | null = null; - parentArc: Arc | null = null; loreId: string | null = null; availablePages: Page[] = []; - private allChaptersById: Record = {}; constructor( private route: ActivatedRoute, @@ -94,30 +91,10 @@ 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, this.translate)); }); } - describePrerequisite(p: Prerequisite): string { - switch (p.kind) { - case 'QUEST_COMPLETED': - return this.translate.instant('chapterView.prereqQuestCompleted', { - name: this.allChaptersById[p.questId]?.name ?? '?' - }); - case 'SESSION_REACHED': - return this.translate.instant('chapterView.prereqSessionReached', { n: p.minSessionNumber }); - case 'FLAG_SET': - return this.translate.instant('chapterView.prereqFlagSet', { flag: p.flagName }); - } - } - titleOfRelated(pageId: string): string { return this.availablePages.find(p => p.id === pageId)?.title ?? this.translate.instant('chapterView.deletedPage'); diff --git a/web/src/app/campaigns/gap-action.helper.ts b/web/src/app/campaigns/gap-action.helper.ts new file mode 100644 index 0000000..6143ab7 --- /dev/null +++ b/web/src/app/campaigns/gap-action.helper.ts @@ -0,0 +1,54 @@ +import { ReadinessGap } from '../services/readiness.model'; + +/** + * Action de correction d'un manque de guidage : lien profond + query params. + * + * Le bouton « Corriger » ne se contente plus de NAVIGUER : selon la règle, il ouvre + * directement le BON OUTIL sur la page cible — + * - chapitre vide → l'éditeur du chapitre avec le panneau « Générer des scènes » déployé ; + * - manques de scène → l'ÉDITEUR de la scène avec la section fautive dépliée + * (combat / branches / lieu explorable) ; + * - manques de quête (nœud mort, prérequis cassé, sans nœud) → l'éditeur de la quête. + */ +export interface GapAction { + link: string[]; + queryParams?: Record; +} + +export function gapAction(gap: ReadinessGap, campaignId: string): GapAction { + const base = ['/campaigns', campaignId]; + switch (gap.entityType) { + case 'SCENE': { + const link = [...base, 'arcs', gap.arcId ?? '', 'chapters', gap.chapterId ?? '', 'scenes', gap.entityId, 'edit']; + const focus = sceneFocusFor(gap.ruleId); + return { link, queryParams: focus ? { focus } : undefined }; + } + case 'CHAPTER': + if (gap.ruleId === 'CHAP-001-NO-SCENE') { + return { + link: [...base, 'arcs', gap.arcId ?? '', 'chapters', gap.entityId, 'edit'], + queryParams: { assist: 'draft-scenes' } + }; + } + return { link: [...base, 'arcs', gap.arcId ?? '', 'chapters', gap.entityId] }; + case 'ARC': + return { link: [...base, 'arcs', gap.entityId] }; + case 'QUEST': + // Nœuds et prérequis s'éditent sur quest-edit — pas sur la fiche de lecture. + return { link: [...base, 'quests', gap.entityId, 'edit'] }; + case 'NPC': + return { link: [...base, 'npcs', gap.entityId] }; + case 'ENEMY': + return { link: [...base, 'enemies', gap.entityId] }; + default: + return { link: base }; + } +} + +/** Section de l'éditeur de scène à déplier selon la règle en défaut. */ +function sceneFocusFor(ruleId: string): string | undefined { + if (ruleId.startsWith('SCENE-011') || ruleId.startsWith('SCENE-012')) return 'combat'; + if (ruleId.startsWith('SCENE-010')) return 'branches'; + if (ruleId.startsWith('SCENE-041') || ruleId.startsWith('SCENE-042')) return 'rooms'; + return undefined; +} diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts index ca9de6b..0829458 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts @@ -84,7 +84,11 @@ export class NotebookDetailComponent implements OnInit { private loadTree(): void { loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, undefined, this.enemyService) .subscribe({ - next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; }, + next: (data) => { + // Pas d'envoi de contenu généré vers l'arc SYSTEM (plomberie des quêtes libres). + this.arcs = data.arcs.filter(a => a.type !== 'SYSTEM'); + this.chaptersByArc = data.chaptersByArc; + }, error: () => { /* cibles indisponibles : les cartes le signaleront */ } }); } diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html index 9348279..1cbcbb3 100644 --- a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html @@ -40,6 +40,16 @@ {{ 'playthroughDetail.facts' | translate }}
+ +
+

{{ 'playthroughDetail.prepTitle' | translate }}

+ +
+ +
+

{{ 'playthroughDetail.clocksTitle' | translate }}

+ +
diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts index 3a7f50c..fb654e1 100644 --- a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts @@ -19,6 +19,8 @@ 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'; +import { ClocksManagerComponent } from '../../../shared/clocks-manager/clocks-manager.component'; +import { SessionPrepPanelComponent } from '../../../shared/session-prep-panel/session-prep-panel.component'; /** * Vue détail d'une Partie (Playthrough). @@ -27,7 +29,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-playthrough-detail', - imports: [RouterModule, LucideAngularModule, TranslatePipe], + imports: [RouterModule, LucideAngularModule, TranslatePipe, ClocksManagerComponent, SessionPrepPanelComponent], templateUrl: './playthrough-detail.component.html', styleUrls: ['./playthrough-detail.component.scss'] }) diff --git a/web/src/app/campaigns/quest/quest-edit/quest-edit.component.html b/web/src/app/campaigns/quest/quest-edit/quest-edit.component.html new file mode 100644 index 0000000..1ff26a4 --- /dev/null +++ b/web/src/app/campaigns/quest/quest-edit/quest-edit.component.html @@ -0,0 +1,141 @@ +
+ + + + + +

{{ 'questEdit.optionalHint' | translate }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + {{ 'questEdit.illustrationsHint' | translate }} + + + + + {{ 'questEdit.unlockHint' | translate }} + + + + + + @if (!isAttached) { + + {{ 'questEdit.nodesHint' | translate }} + + + + } + + + +
+ +
+
+ + + +
+ + +
+
+ + +
+
+ + + @if (loreId) { + + + + {{ 'questEdit.relatedPagesHint' | translate }} + + } + + @if (!loreId) { +
+ {{ 'questEdit.noLoreHint' | translate }} +
+ } + + + +
diff --git a/web/src/app/campaigns/quest/quest-edit/quest-edit.component.scss b/web/src/app/campaigns/quest/quest-edit/quest-edit.component.scss new file mode 100644 index 0000000..d780bd9 --- /dev/null +++ b/web/src/app/campaigns/quest/quest-edit/quest-edit.component.scss @@ -0,0 +1,40 @@ +.edit-page { + padding: 2.5rem 2rem; + max-width: 720px; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + + .header-actions { + display: flex; + gap: 0.5rem; + } +} + +.edit-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +// Bandeau « tout est optionnel sauf le titre ». +.optional-hint { + margin: 0; + padding: 0.6rem 0.85rem; + background: rgba(108, 99, 255, 0.08); + border: 1px solid rgba(108, 99, 255, 0.25); + border-radius: 8px; + color: #a5b4fc; + font-size: 0.85rem; +} + +.btn-danger { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-left: auto; +} diff --git a/web/src/app/campaigns/quest/quest-edit/quest-edit.component.ts b/web/src/app/campaigns/quest/quest-edit/quest-edit.component.ts new file mode 100644 index 0000000..062f22f --- /dev/null +++ b/web/src/app/campaigns/quest/quest-edit/quest-edit.component.ts @@ -0,0 +1,235 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { LucideAngularModule, Trash2 } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { CampaignService } from '../../../services/campaign.service'; +import { QuestService } from '../../../services/quest.service'; +import { CampaignFlagService } from '../../../services/campaign-flag.service'; +import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; +import { PageService } from '../../../services/page.service'; +import { LayoutService } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Quest, QuestCreate, Prerequisite, QuestNodeRef, Chapter, Scene } from '../../../services/campaign.model'; +import { Page } from '../../../services/page.model'; +import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; +import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component'; +import { PrerequisiteEditorComponent } from '../../../shared/prerequisite-editor/prerequisite-editor.component'; +import { NodePickerComponent } from '../../../shared/node-picker/node-picker.component'; +import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component'; +import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; +import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; + +/** + * Création / édition d'une Quête (Niveau 1). Un seul composant pour les routes : + * - /campaigns/:campaignId/quests/create (questId absent → mode création) + * - /campaigns/:campaignId/quests/:questId/edit (mode édition) + * + * Identité + prérequis (cœur d'une quête) + champs narratifs + illustrations, + * pages Lore liées et nœuds narratifs traversés (chapitres / scènes). + */ +@Component({ + selector: 'app-quest-edit', + imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent, ExpandableSectionComponent, PrerequisiteEditorComponent, NodePickerComponent, LoreLinkPickerComponent, ImageGalleryComponent, TranslatePipe], + templateUrl: './quest-edit.component.html', + styleUrls: ['./quest-edit.component.scss'] +}) +export class QuestEditComponent implements OnInit, OnDestroy { + readonly Trash2 = Trash2; + readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; + selectedIcon: string | null = null; + + form: FormGroup; + campaignId = ''; + questId: string | null = null; // null = mode création + quest: Quest | null = null; + /** Arc de rattachement : query param à la création (arc HUB), conservé à l'édition. */ + arcIdParam: string | null = null; + + prerequisites: Prerequisite[] = []; + /** Autres quêtes de la campagne (cibles candidates pour QUEST_COMPLETED). */ + availableQuests: Quest[] = []; + availableFlagNames: string[] = []; + + /** Nœuds narratifs (chapitres / scènes) traversés par la quête. */ + nodes: QuestNodeRef[] = []; + /** + * Nœuds de PLOMBERIE (conteneur de scènes en arc SYSTEM) : jamais montrés dans le + * sélecteur, ré-injectés tels quels à la sauvegarde pour ne pas casser le lien. + */ + private containerNodes: QuestNodeRef[] = []; + /** Chapitres / scènes de la campagne (alimentent le node-picker). */ + chapters: Chapter[] = []; + scenes: Scene[] = []; + + illustrationImageIds: string[] = []; + relatedPageIds: string[] = []; + loreId: string | null = null; + availablePages: Page[] = []; + + get isCreate(): boolean { return this.questId === null; } + /** + * Quête rattachée à un arc HUB : son CONTENEUR de scènes est géré automatiquement + * (créé avec la quête, fusionné dans l'arbre) → le sélecteur de nœuds manuels est + * masqué pour ne pas exposer cette plomberie. Il ne sert qu'aux quêtes TRANSVERSES. + */ + get isAttached(): boolean { + return !!(this.questId ? this.quest?.arcId : this.arcIdParam); + } + get prereqFilled(): boolean { return this.prerequisites.length > 0; } + get gmNotesFilled(): boolean { return !!this.form.value.gmNotes; } + get objectivesFilled(): boolean { + const v = this.form.value; + return !!(v.playerObjectives || v.narrativeStakes); + } + get nodesFilled(): boolean { return this.nodes.length > 0; } + get illustrationsFilled(): boolean { return this.illustrationImageIds.length > 0; } + get loreFilled(): boolean { return this.relatedPageIds.length > 0; } + + constructor( + private fb: FormBuilder, + private route: ActivatedRoute, + private router: Router, + private campaignService: CampaignService, + private questService: QuestService, + private campaignFlagService: CampaignFlagService, + private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, + private pageService: PageService, + private layoutService: LayoutService, + private pageTitleService: PageTitleService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) { + this.form = this.fb.group({ + name: ['', Validators.required], + description: [''], + gmNotes: [''], + playerObjectives: [''], + narrativeStakes: [''] + }); + } + + ngOnInit(): void { + this.route.paramMap.subscribe(pm => { + this.campaignId = pm.get('campaignId')!; + this.questId = pm.get('questId'); // null sur la route /create + this.arcIdParam = this.route.snapshot.queryParamMap.get('arcId'); // ?arcId= depuis un arc HUB + this.loadAll(); + }); + } + + private loadAll(): void { + forkJoin({ + campaign: this.campaignService.getCampaignById(this.campaignId), + allCampaigns: this.campaignService.getAllCampaigns(), + quests: this.questService.getByCampaign(this.campaignId), + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService) + }).pipe( + switchMap(data => { + const lid = data.campaign.loreId ?? null; + const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]); + return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid }))); + }) + ).subscribe(({ campaign, allCampaigns, quests, treeData, pages, loreId }) => { + this.loreId = loreId; + this.availablePages = pages; + // L'arc SYSTEM (« Quêtes libres ») est de la plomberie : ses chapitres-conteneurs + // et leurs scènes ne sont pas des cibles de lien pour le node-picker. + const systemArcIds = new Set((treeData.arcs ?? []).filter(a => a.type === 'SYSTEM').map(a => a.id)); + const containerChapterIds = new Set(Object.values(treeData.chaptersByArc).flat() + .filter(ch => systemArcIds.has(ch.arcId)).map(ch => ch.id)); + this.chapters = Object.values(treeData.chaptersByArc).flat() + .filter(ch => !systemArcIds.has(ch.arcId)); + this.scenes = Object.values(treeData.scenesByChapter).flat() + .filter(sc => !containerChapterIds.has(sc.chapterId)); + + this.availableQuests = quests.filter(q => q.id !== this.questId); + const current = this.questId ? quests.find(q => q.id === this.questId) ?? null : null; + this.quest = current; + if (current) { + this.selectedIcon = current.icon ?? null; + this.prerequisites = [...(current.prerequisites ?? [])]; + const allNodes = current.nodes ?? []; + this.containerNodes = allNodes.filter(n => n.nodeType === 'CHAPTER' && containerChapterIds.has(n.nodeId)); + this.nodes = allNodes.filter(n => !this.containerNodes.includes(n)); + this.relatedPageIds = [...(current.relatedPageIds ?? [])]; + this.illustrationImageIds = [...(current.illustrationImageIds ?? [])]; + this.pageTitleService.set(current.name); + this.form.patchValue({ + name: current.name, + description: current.description ?? '', + gmNotes: current.gmNotes ?? '', + playerObjectives: current.playerObjectives ?? '', + narrativeStakes: current.narrativeStakes ?? '' + }); + } + + this.campaignFlagService.listReferenced(this.campaignId).subscribe({ + next: names => this.availableFlagNames = names + }); + + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); + }); + } + + onPrerequisitesChange(next: Prerequisite[]): void { this.prerequisites = next; } + + submit(): void { + if (this.form.invalid) return; + const payload: QuestCreate = { + name: this.form.value.name, + // Création : arcId du query param (arc HUB) ; édition : on conserve le rattachement existant. + arcId: this.questId ? (this.quest?.arcId ?? null) : this.arcIdParam, + description: this.form.value.description, + icon: this.selectedIcon, + order: this.quest?.order ?? this.availableQuests.length + 1, + prerequisites: this.prerequisites, + nodes: [...this.containerNodes, ...this.nodes], + gmNotes: this.form.value.gmNotes, + playerObjectives: this.form.value.playerObjectives, + narrativeStakes: this.form.value.narrativeStakes, + relatedPageIds: this.relatedPageIds, + illustrationImageIds: this.illustrationImageIds + }; + + const op$ = this.questId + ? this.questService.update(this.campaignId, this.questId, { ...payload, campaignId: this.campaignId } as Quest) + : this.questService.create(this.campaignId, payload); + + op$.subscribe({ + next: created => this.router.navigate(['/campaigns', this.campaignId, 'quests', this.questId ?? created.id]), + error: () => console.error('Erreur lors de l\'enregistrement de la quête') + }); + } + + delete(): void { + if (!this.questId) return; + this.confirmDialog.confirm({ + title: this.translate.instant('questEdit.deleteTitle'), + message: this.translate.instant('questEdit.deleteMessage', { name: this.quest?.name }), + details: [this.translate.instant('questEdit.irreversible')], + confirmLabel: this.translate.instant('common.delete'), + variant: 'danger' + }).then(ok => { + if (!ok || !this.questId) return; + this.questService.delete(this.campaignId, this.questId).subscribe({ + next: () => this.router.navigate(['/campaigns', this.campaignId, 'quests']), + error: () => console.error('Erreur lors de la suppression de la quête') + }); + }); + } + + cancel(): void { + this.router.navigate(['/campaigns', this.campaignId, 'quests']); + } + + ngOnDestroy(): void {} +} diff --git a/web/src/app/campaigns/quest/quest-list/quest-list.component.html b/web/src/app/campaigns/quest/quest-list/quest-list.component.html new file mode 100644 index 0000000..5c97e81 --- /dev/null +++ b/web/src/app/campaigns/quest/quest-list/quest-list.component.html @@ -0,0 +1,39 @@ +
+
+ + + +
+ +
+

{{ 'questList.title' | translate }}

+

{{ 'questList.hint' | translate }}

+
+ + @if (quests.length === 0) { +

{{ 'questList.empty' | translate }}

+ } + +
+ @for (q of quests; track q.id) { + + } +
+
diff --git a/web/src/app/campaigns/quest/quest-list/quest-list.component.scss b/web/src/app/campaigns/quest/quest-list/quest-list.component.scss new file mode 100644 index 0000000..5fd8180 --- /dev/null +++ b/web/src/app/campaigns/quest/quest-list/quest-list.component.scss @@ -0,0 +1,84 @@ +.quest-page { + padding: 1.75rem 1.5rem; + max-width: 760px; +} + +.quest-toolbar { + display: flex; + align-items: center; + margin-bottom: 1rem; + + .spacer { flex: 1; } +} + +.btn-back, +.btn-create { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.85rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.85rem; + border: 1px solid #374151; + background: #1f2937; + color: #d1d5db; +} + +.btn-create { + background: #6c63ff; + border-color: #6c63ff; + color: #fff; +} + +.quest-header { + h1 { + display: inline-flex; + align-items: center; + gap: 0.5rem; + } + + .quest-hint { + color: #9ca3af; + font-size: 0.85rem; + margin-top: 0.25rem; + } +} + +.quest-empty { + color: #6b7280; + padding: 2rem 0; +} + +.quest-grid { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1rem; +} + +.quest-card { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.7rem 0.9rem; + border: 1px solid #374151; + border-radius: 8px; + background: #1f2937; + color: #e5e7eb; + cursor: pointer; + text-align: left; + transition: border-color 0.15s; + + &:hover { border-color: #6c63ff; } + + .quest-card-icon { color: #a5b4fc; flex: 0 0 auto; } + .quest-card-name { flex: 1; font-weight: 600; } + .quest-card-cond { opacity: 0.85; } + .quest-del { + opacity: 0.6; + display: inline-flex; + &:hover { opacity: 1; color: #f87171; } + } +} diff --git a/web/src/app/campaigns/quest/quest-list/quest-list.component.ts b/web/src/app/campaigns/quest/quest-list/quest-list.component.ts new file mode 100644 index 0000000..8183791 --- /dev/null +++ b/web/src/app/campaigns/quest/quest-list/quest-list.component.ts @@ -0,0 +1,89 @@ +import { Component, OnInit, DestroyRef } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Plus, Trash2, Flag } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { QuestService } from '../../../services/quest.service'; +import { Quest } from '../../../services/campaign.model'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; +import { DataSyncService } from '../../../services/data-sync.service'; +import { resolveCampaignIcon } from '../../campaign-icons'; + +/** + * Liste des quêtes d'une campagne (Niveau 1). Quêtes ORTHOGONALES à l'arbre. + * Route : /campaigns/:campaignId/quests + */ +@Component({ + selector: 'app-quest-list', + imports: [LucideAngularModule, TranslatePipe], + templateUrl: './quest-list.component.html', + styleUrls: ['./quest-list.component.scss'] +}) +export class QuestListComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly Flag = Flag; + readonly resolveCampaignIcon = resolveCampaignIcon; + + campaignId = ''; + quests: Quest[] = []; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: QuestService, + private campaignSidebar: CampaignSidebarService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService, + private dataSync: DataSyncService, + private destroyRef: DestroyRef + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.load(); + } + this.dataSync.onChange(this.destroyRef, () => { + this.load(); + if (this.campaignId) this.campaignSidebar.show(this.campaignId); + }); + } + + load(): void { + this.service.getByCampaign(this.campaignId).subscribe({ + // Seules les quêtes TRANSVERSES (non rattachées à un arc HUB) : celles d'un arc + // apparaissent sous cet arc dans l'arbre / sur la fiche de l'arc. + next: list => this.quests = [...list].filter(q => !q.arcId).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), + error: () => this.quests = [] + }); + } + + create(): void { + this.router.navigate(['/campaigns', this.campaignId, 'quests', 'create']); + } + + open(q: Quest): void { + this.router.navigate(['/campaigns', this.campaignId, 'quests', q.id]); + } + + remove(q: Quest, ev: Event): void { + ev.stopPropagation(); + this.confirmDialog.confirm({ + title: this.translate.instant('questList.deleteTitle'), + message: this.translate.instant('questList.deleteMessage', { name: q.name }), + details: [this.translate.instant('questList.irreversible')], + confirmLabel: this.translate.instant('common.delete'), + variant: 'danger' + }).then(ok => { + if (!ok || !q.id) return; + this.service.delete(this.campaignId, q.id).subscribe(() => this.load()); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/quest/quest-view/quest-view.component.html b/web/src/app/campaigns/quest/quest-view/quest-view.component.html new file mode 100644 index 0000000..5e5763e --- /dev/null +++ b/web/src/app/campaigns/quest/quest-view/quest-view.component.html @@ -0,0 +1,117 @@ +@if (quest) { +
+
+
+

+ @if (quest.icon) { + + } + {{ quest.name }} +

+

{{ 'questView.subtitle' | translate }}

+
+
+ @if (containerChapter) { + + } + + +
+
+ + + @if ((quest.illustrationImageIds?.length ?? 0) > 0) { +
+ +
+ } + + +
+

📜 {{ 'questView.synopsisTitle' | translate }}

+ @if (quest.description?.trim()) { +

{{ quest.description }}

+ } @else { +

{{ 'questView.notProvided' | translate }}

+ } +
+ + +
+
+

🎯 {{ 'questView.playerObjectivesTitle' | translate }}

+ @if (quest.playerObjectives?.trim()) { +

{{ quest.playerObjectives }}

+ } @else { +

{{ 'questView.notProvided' | translate }}

+ } +
+
+

{{ 'questView.narrativeStakesTitle' | translate }}

+ @if (quest.narrativeStakes?.trim()) { +

{{ quest.narrativeStakes }}

+ } @else { +

{{ 'questView.notProvided' | translate }}

+ } +
+
+ + + @if ((quest.prerequisites?.length ?? 0) > 0) { +
+

🔓 {{ 'questView.prerequisitesTitle' | translate }}

+
+ @for (prereq of quest.prerequisites; track $index) { + {{ prereqLabel(prereq) }} + } +
+
+ } + + + @if (visibleNodes.length > 0) { +
+

📍 {{ 'questView.nodesTitle' | translate }}

+
+ @for (n of visibleNodes; track $index) { + + + {{ nodeLabel(n) }} + + } +
+
+ } + + + @if (quest.gmNotes?.trim()) { +
+

🔒 {{ 'questView.gmNotesTitle' | translate }}

+

{{ quest.gmNotes }}

+
+ } + + + @if (loreId && (quest.relatedPageIds?.length ?? 0) > 0) { +
+

🔗 {{ 'questView.relatedPagesTitle' | translate }}

+
+ @for (relId of quest.relatedPageIds; track relId) { + + {{ titleOfRelated(relId) }} + + } +
+
+ } +
+} diff --git a/web/src/app/campaigns/quest/quest-view/quest-view.component.scss b/web/src/app/campaigns/quest/quest-view/quest-view.component.scss new file mode 100644 index 0000000..49a76c4 --- /dev/null +++ b/web/src/app/campaigns/quest/quest-view/quest-view.component.scss @@ -0,0 +1,5 @@ +// La mise en page réutilise les classes partagées de styles/_view.scss +// (.view-page, .view-header, .view-section, .view-chips, .view-chip…). +// Les chips de prérequis / nœuds sont des statiques +// (pas de :host hover, pas d'href) — aucun style spécifique requis ici. +:host { display: block; } diff --git a/web/src/app/campaigns/quest/quest-view/quest-view.component.ts b/web/src/app/campaigns/quest/quest-view/quest-view.component.ts new file mode 100644 index 0000000..2fed7ed --- /dev/null +++ b/web/src/app/campaigns/quest/quest-view/quest-view.component.ts @@ -0,0 +1,204 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { LucideAngularModule, Pencil, Trash2, BookOpen, MapPin, Network } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { resolveCampaignIcon } from '../../campaign-icons'; +import { CampaignService } from '../../../services/campaign.service'; +import { QuestService } from '../../../services/quest.service'; +import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; +import { PageService } from '../../../services/page.service'; +import { LayoutService } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Quest, Prerequisite, QuestNodeRef, Chapter, Scene } 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'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; + +/** + * Écran de consultation d'une Quête (lecture seule, Niveau 1). + * Route : /campaigns/:campaignId/quests/:questId + * + * Les quêtes sont ORTHOGONALES à l'arbre Arc→Chapitre→Scène. La vue résout les + * références faibles (prérequis, nœuds CHAPTER/SCENE, pages Lore) en libellés. + */ +@Component({ + selector: 'app-quest-view', + imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe], + templateUrl: './quest-view.component.html', + styleUrls: ['./quest-view.component.scss'] +}) +export class QuestViewComponent implements OnInit, OnDestroy { + readonly Pencil = Pencil; + readonly Trash2 = Trash2; + readonly BookOpen = BookOpen; + readonly MapPin = MapPin; + readonly Network = Network; + readonly resolveCampaignIcon = resolveCampaignIcon; + + campaignId = ''; + questId = ''; + quest: Quest | null = null; + + loreId: string | null = null; + availablePages: Page[] = []; + /** Autres quêtes — pour résoudre les prérequis QUEST_COMPLETED en noms. */ + availableQuests: Quest[] = []; + /** Chapitres / scènes de la campagne — pour résoudre les nœuds en libellés. */ + chapters: Chapter[] = []; + scenes: Scene[] = []; + /** Arcs SYSTEM (« Quêtes libres ») : leurs chapitres-conteneurs sont de la plomberie. */ + private systemArcIds = new Set(); + + constructor( + private route: ActivatedRoute, + private router: Router, + private campaignService: CampaignService, + private questService: QuestService, + private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, + private pageService: PageService, + private layoutService: LayoutService, + private pageTitleService: PageTitleService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + this.route.paramMap.subscribe(pm => { + const newCampaignId = pm.get('campaignId')!; + const newQuestId = pm.get('questId')!; + if (newQuestId !== this.questId || newCampaignId !== this.campaignId) { + this.campaignId = newCampaignId; + this.questId = newQuestId; + this.load(); + } + }); + } + + private load(): void { + forkJoin({ + campaign: this.campaignService.getCampaignById(this.campaignId), + allCampaigns: this.campaignService.getAllCampaigns(), + quest: this.questService.getById(this.campaignId, this.questId), + quests: this.questService.getByCampaign(this.campaignId), + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService) + }).pipe( + switchMap(data => { + const lid = data.campaign.loreId ?? null; + const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]); + return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid }))); + }) + ).subscribe(({ campaign, allCampaigns, quest, quests, treeData, pages, loreId }) => { + this.quest = quest; + this.loreId = loreId; + this.availablePages = pages; + this.availableQuests = quests; + this.chapters = Object.values(treeData.chaptersByArc).flat(); + this.scenes = Object.values(treeData.scenesByChapter).flat(); + this.systemArcIds = new Set((treeData.arcs ?? []) + .filter(a => a.type === 'SYSTEM').map(a => a.id!)); + this.pageTitleService.set(quest.name); + + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); + }); + } + + titleOfRelated(pageId: string): string { + return this.availablePages.find(p => p.id === pageId)?.title + ?? this.translate.instant('questView.deletedPage'); + } + + /** Libellé lisible d'un prérequis (réutilisé en lecture seule). */ + prereqLabel(prereq: Prerequisite): string { + switch (prereq.kind) { + case 'QUEST_COMPLETED': { + const q = this.availableQuests.find(x => x.id === prereq.questId); + const name = q?.name ?? this.translate.instant('questView.deletedQuest'); + return this.translate.instant('questView.prereqQuestCompleted', { name }); + } + case 'SESSION_REACHED': + return this.translate.instant('questView.prereqSessionReached', { n: prereq.minSessionNumber }); + case 'FLAG_SET': + return this.translate.instant('questView.prereqFlagSet', { flag: prereq.flagName }); + } + } + + /** + * Nœuds à AFFICHER : les vrais liens narratifs. Le conteneur de scènes de la quête + * (chapitre d'un arc SYSTEM) est de la plomberie — ses scènes sont déjà visibles + * dans l'arbre sous la quête, inutile de montrer le lien technique. + */ + get visibleNodes(): QuestNodeRef[] { + return (this.quest?.nodes ?? []).filter(n => { + if (n.nodeType !== 'CHAPTER') return true; + const ch = this.chapters.find(c => c.id === n.nodeId); + return !ch || !this.systemArcIds.has(ch.arcId); + }); + } + + /** Libellé d'un nœud narratif (chapitre, ou « chapitre › scène »). */ + nodeLabel(n: QuestNodeRef): string { + if (n.nodeType === 'CHAPTER') { + return this.chapters.find(c => c.id === n.nodeId)?.name + ?? this.translate.instant('questView.deletedNode'); + } + const scene = this.scenes.find(s => s.id === n.nodeId); + if (!scene) return this.translate.instant('questView.deletedNode'); + const chapter = this.chapters.find(c => c.id === scene.chapterId); + return chapter ? `${chapter.name} › ${scene.name}` : scene.name; + } + + /** + * Conteneur de scènes de la quête : premier nœud CHAPTER résolu. C'est lui qui porte + * la CARTE (graphe des scènes) — indispensable depuis la fusion quête/conteneur dans + * l'arbre, sinon le graphe d'une quête de hub serait inaccessible. + */ + get containerChapter(): Chapter | null { + for (const n of this.quest?.nodes ?? []) { + if (n.nodeType !== 'CHAPTER') continue; + const ch = this.chapters.find(c => c.id === n.nodeId); + if (ch) return ch; + } + return null; + } + + /** Ouvre la carte (graphe) des scènes de la quête — création libre de scènes incluse. */ + openGraph(): void { + const ch = this.containerChapter; + if (!ch) return; + this.router.navigate(['/campaigns', this.campaignId, 'arcs', ch.arcId, 'chapters', ch.id, 'graph']); + } + + editMode(): void { + this.router.navigate(['/campaigns', this.campaignId, 'quests', this.questId, 'edit']); + } + + /** Suppression simple : une quête n'a pas d'enfants (les nœuds sont des références faibles). */ + deleteQuest(): void { + if (!this.quest) return; + const quest = this.quest; + this.confirmDialog.confirm({ + title: this.translate.instant('questView.deleteTitle'), + message: this.translate.instant('questView.deleteMessage', { name: quest.name }), + details: [this.translate.instant('questView.irreversible')], + confirmLabel: this.translate.instant('common.delete'), + variant: 'danger' + }).then(ok => { + if (!ok || !quest.id) return; + this.questService.delete(this.campaignId, quest.id).subscribe({ + next: () => this.router.navigate(['/campaigns', this.campaignId, 'quests']), + error: () => console.error('Erreur lors de la suppression de la quête') + }); + }); + } + + ngOnDestroy(): void { + // Volontairement vide : la sidebar reste prise en charge par le composant suivant. + } +} diff --git a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts index cd5ff8d..5dcbfad 100644 --- a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts +++ b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts @@ -39,18 +39,25 @@ export class RandomTableViewComponent implements OnInit { ) {} ngOnInit(): void { - const params = this.route.snapshot.paramMap; - this.campaignId = params.get('campaignId'); - this.tableId = params.get('tableId'); - if (this.tableId) { - this.service.getById(this.tableId).subscribe({ - next: t => this.table = t, - error: () => this.back() - }); - } - if (this.campaignId) { - this.campaignSidebar.show(this.campaignId); - } + this.route.paramMap.subscribe(pm => { + const newCampaignId = pm.get('campaignId'); + const newTableId = pm.get('tableId'); + if (newCampaignId === this.campaignId && newTableId === this.tableId) return; + this.campaignId = newCampaignId; + this.tableId = newTableId; + this.table = null; + this.lastRoll = null; + this.matched = null; + if (this.tableId) { + this.service.getById(this.tableId).subscribe({ + next: t => this.table = t, + error: () => this.back() + }); + } + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + } + }); } roll(): void { diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html index a11c8de..ea4e105 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html @@ -26,93 +26,17 @@
- -
- - - - {{ 'sceneEdit.illustrationsHint' | translate }} -
+ +

{{ 'sceneEdit.optionalHint' | translate }}

- -
- - {{ 'sceneEdit.battlemapHint' | translate }} - -
- - -
- - @if (battlemapSource === 'DUNGEON_ALCHEMIST') { -
- {{ 'sceneEdit.battlemapMedia' | translate }} - @if (battlemapMediaFileId) { - {{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }} - - } @else { - - } -
- -
- {{ 'sceneEdit.battlemapData' | translate }} - @if (battlemapDataFileId) { - {{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }} - - } @else { - - } -
- } @else { -
- {{ 'sceneEdit.battlemapDd2vttSlot' | translate }} - @if (battlemapDataFileId) { - {{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }} - - } @else { - - } -
- @if (battlemapDd2vttNoImage) { - {{ 'sceneEdit.battlemapDd2vttNoImage' | translate }} - } - {{ 'sceneEdit.battlemapDd2vttHint' | translate }} - } -
+ + +
+ +
+ + + {{ 'sceneEdit.nodeTypeHint' | translate }} +
+ + + + + + {{ 'sceneEdit.illustrationsHint' | translate }} + + + + + {{ 'sceneEdit.battlemapHint' | translate }} + + @for (bm of battlemaps; track $index; let i = $index) { +
+
+ + +
+ +
+ + +
+ + @if (bm.source === 'DUNGEON_ALCHEMIST') { +
+ {{ 'sceneEdit.battlemapMedia' | translate }} + @if (bm.mediaFileId) { + {{ bm.mediaName || ('sceneEdit.battlemapAttached' | translate) }} + + } @else { + + } +
+ +
+ {{ 'sceneEdit.battlemapData' | translate }} + @if (bm.dataFileId) { + {{ bm.dataName || ('sceneEdit.battlemapAttached' | translate) }} + + } @else { + + } +
+ } @else { +
+ {{ 'sceneEdit.battlemapDd2vttSlot' | translate }} + @if (bm.dataFileId) { + {{ bm.dataName || ('sceneEdit.battlemapAttached' | translate) }} + + } @else { + + } +
+ @if (bm.dd2vttNoImage) { + {{ 'sceneEdit.battlemapDd2vttNoImage' | translate }} + } + {{ 'sceneEdit.battlemapDd2vttHint' | translate }} + } +
+ } + + +
+ - +
@@ -163,7 +201,7 @@ - +
+ +
+ + @if (error) { +

{{ error }}

+ } + + @if (generated && fields.length === 0 && !loading) { +

{{ 'entityAssist.empty' | translate }}

+ } + + @if (fields.length > 0) { +
    + @for (f of fields; track f.key) { +
  • + + @if (f.currentValue) { +
    + {{ 'entityAssist.current' | translate }} + {{ f.currentValue }} +
    + } +
    + {{ 'entityAssist.proposed' | translate }} + {{ f.proposedValue }} +
    +
  • + } +
+ +
+ +
+ } +
+ } +
diff --git a/web/src/app/shared/entity-assist-panel/entity-assist-panel.component.scss b/web/src/app/shared/entity-assist-panel/entity-assist-panel.component.scss new file mode 100644 index 0000000..7dfefc3 --- /dev/null +++ b/web/src/app/shared/entity-assist-panel/entity-assist-panel.component.scss @@ -0,0 +1,147 @@ +.entity-assist { + border: 1px solid #2a2440; + border-radius: 10px; + background: #12101d; + overflow: hidden; +} + +.assist-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.6rem 0.85rem; + background: transparent; + border: none; + color: #d6ccff; + cursor: pointer; + text-align: left; + + &:hover { background: #171327; } + + .spark { color: #b9a8ff; } + .assist-title { font-weight: 600; font-size: 0.9rem; } +} + +.assist-body { + padding: 0.2rem 0.85rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.assist-hint { + margin: 0; + color: #9ca3af; + font-size: 0.8rem; +} + +.assist-prompt { + display: flex; + gap: 0.5rem; + align-items: flex-start; + + textarea { + flex: 1; + resize: vertical; + min-height: 2.4rem; + } +} + +.btn-generate, +.btn-apply { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.45rem 0.8rem; + border-radius: 7px; + border: 1px solid #3a3068; + background: #201b3a; + color: #d6ccff; + cursor: pointer; + font-size: 0.82rem; + white-space: nowrap; + transition: background 0.15s, color 0.15s; + + &:hover:not(:disabled) { background: #2c2358; color: #fff; } + &:disabled { opacity: 0.5; cursor: default; } +} + +.btn-apply { + border-color: #2f6b46; + background: #163326; + color: #a7f3d0; + &:hover:not(:disabled) { background: #1c4531; color: #fff; } +} + +.spin { animation: assist-spin 0.9s linear infinite; } +@keyframes assist-spin { to { transform: rotate(360deg); } } + +.assist-error { margin: 0; color: #fca5a5; font-size: 0.82rem; } +.assist-empty { margin: 0; color: #9ca3af; font-style: italic; font-size: 0.82rem; } + +.field-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.field-card { + padding: 0.55rem 0.7rem; + background: #16131f; + border: 1px solid #272233; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 0.3rem; + + &.rejected { opacity: 0.5; } +} + +.field-check { + display: flex; + align-items: center; + gap: 0.45rem; + cursor: pointer; + + input { accent-color: #8b80ff; } + .field-name { font-weight: 600; color: #e5e7eb; font-size: 0.83rem; } +} + +.field-current, +.field-proposed { + display: flex; + gap: 0.4rem; + font-size: 0.82rem; + line-height: 1.3; + + .tag { + flex-shrink: 0; + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.05rem 0.35rem; + border-radius: 4px; + height: fit-content; + margin-top: 0.1rem; + } + .val { white-space: pre-wrap; word-break: break-word; } +} + +.field-current { + .tag { background: #2a2233; color: #9ca3af; } + .val { color: #8b8595; text-decoration: line-through; text-decoration-color: #4b4458; } +} + +.field-proposed { + .tag { background: #201b3a; color: #b9a8ff; } + .val { color: #ede9ff; } +} + +.assist-actions { + display: flex; + justify-content: flex-end; +} diff --git a/web/src/app/shared/entity-assist-panel/entity-assist-panel.component.ts b/web/src/app/shared/entity-assist-panel/entity-assist-panel.component.ts new file mode 100644 index 0000000..37554cb --- /dev/null +++ b/web/src/app/shared/entity-assist-panel/entity-assist-panel.component.ts @@ -0,0 +1,113 @@ +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { LucideAngularModule, Sparkles, RefreshCw, Check, X, ChevronDown, ChevronRight } from 'lucide-angular'; +import { EntityAssistService } from '../../services/entity-assist.service'; +import { FieldProposal } from '../../services/entity-assist.model'; + +interface ReviewField extends FieldProposal { + accepted: boolean; +} + +/** + * Panneau « Étoffer avec l'IA » (Pilier A — co-création), générique par type d'entité + * narrative ({@code arc|chapter|scene}). Demande au co-MJ des propositions de valeurs pour + * les champs de l'entité, les affiche en diff (actuel → proposé) avec accept/reject champ + * par champ, et ÉMET les champs retenus. + * + *

Ne persiste rien : le parent (arc/chapter/scene-edit) applique les champs retenus au + * FORMULAIRE ; l'utilisateur enregistre ensuite normalement (human-in-the-loop).

+ */ +@Component({ + selector: 'app-entity-assist-panel', + standalone: true, + imports: [FormsModule, TranslatePipe, LucideAngularModule], + templateUrl: './entity-assist-panel.component.html', + styleUrls: ['./entity-assist-panel.component.scss'] +}) +export class EntityAssistPanelComponent implements OnChanges { + + /** Type d'entité : 'arc' | 'chapter' | 'scene'. */ + @Input() entityType = 'scene'; + @Input() entityId = ''; + @Input() campaignId = ''; + /** Émet les champs ACCEPTÉS ; le parent les applique où il veut (formulaire, /apply…). */ + @Output() applied = new EventEmitter(); + + open = false; + instruction = ''; + loading = false; + generated = false; + error = ''; + fields: ReviewField[] = []; + + readonly Sparkles = Sparkles; + readonly RefreshCw = RefreshCw; + readonly Check = Check; + readonly X = X; + readonly ChevronDown = ChevronDown; + readonly ChevronRight = ChevronRight; + + constructor(private assist: EntityAssistService, private translate: TranslateService) {} + + /** + * L'écran d'édition RÉUTILISE ce composant quand on navigue entre entités sœurs + * (même route pattern → pas de recréation). On repart donc d'un état vierge dès que + * l'entité ciblée change, sinon on afficherait les propositions de l'entité précédente. + */ + ngOnChanges(changes: SimpleChanges): void { + if (changes['entityId'] && !changes['entityId'].firstChange) { + this.open = false; + this.instruction = ''; + this.loading = false; + this.generated = false; + this.error = ''; + this.fields = []; + } + } + + toggle(): void { + this.open = !this.open; + } + + generate(): void { + if (!this.entityId || this.loading) return; + this.loading = true; + this.error = ''; + this.generated = false; + this.fields = []; + this.assist.generateFields(this.entityType, this.entityId, this.campaignId, this.instruction.trim() || undefined).subscribe({ + next: proposal => { + this.fields = (proposal.fields ?? []).map(f => ({ ...f, accepted: true })); + this.generated = true; + this.loading = false; + }, + error: () => { + this.error = this.translate.instant('entityAssist.error'); + this.loading = false; + } + }); + } + + get acceptedCount(): number { + return this.fields.filter(f => f.accepted).length; + } + + apply(): void { + const accepted: FieldProposal[] = this.fields + .filter(f => f.accepted) + .map(f => ({ key: f.key, currentValue: f.currentValue, proposedValue: f.proposedValue })); + if (accepted.length > 0) { + this.applied.emit(accepted); + } + this.fields = []; + this.generated = false; + this.open = false; + } + + /** Libellé lisible d'une clé de champ (fallback sur la clé brute si non traduite). */ + fieldLabel(key: string): string { + const label = this.translate.instant('entityAssist.field.' + key); + return label === 'entityAssist.field.' + key ? key : label; + } +} diff --git a/web/src/app/shared/expandable-section/expandable-section.component.html b/web/src/app/shared/expandable-section/expandable-section.component.html index 258788a..e114162 100644 --- a/web/src/app/shared/expandable-section/expandable-section.component.html +++ b/web/src/app/shared/expandable-section/expandable-section.component.html @@ -6,6 +6,9 @@ {{ icon }} } {{ title }} + @if (filled) { + + } diff --git a/web/src/app/shared/expandable-section/expandable-section.component.scss b/web/src/app/shared/expandable-section/expandable-section.component.scss index 3f80c58..2929871 100644 --- a/web/src/app/shared/expandable-section/expandable-section.component.scss +++ b/web/src/app/shared/expandable-section/expandable-section.component.scss @@ -42,6 +42,19 @@ font-size: 1rem; } +// Pastille « cette section contient du contenu » (voir Input `filled`). +.section-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #6c63ff; + flex: 0 0 auto; +} + +.private .section-dot { + background: #fca5a5; +} + .section-content { padding: 1rem 1.1rem 1.2rem 1.1rem; border-top: 1px solid #374151; diff --git a/web/src/app/shared/expandable-section/expandable-section.component.ts b/web/src/app/shared/expandable-section/expandable-section.component.ts index 8c9512f..ac7d919 100644 --- a/web/src/app/shared/expandable-section/expandable-section.component.ts +++ b/web/src/app/shared/expandable-section/expandable-section.component.ts @@ -25,6 +25,12 @@ export class ExpandableSectionComponent { @Input() icon = ''; // Emoji ou caractère unicode (ex: '📍', '📖') @Input() initiallyOpen = false; @Input() variant: 'default' | 'private' = 'default'; // 'private' = notes MJ (couleur différente) + /** + * Pastille « contient du contenu » dans l'en-tête (replié comme déplié). + * Permet de voir d'un coup d'œil quelles sections optionnelles sont remplies + * sans avoir à les ouvrir une à une. Le parent calcule l'état. + */ + @Input() filled = false; isOpen = false; diff --git a/web/src/app/shared/node-picker/node-picker.component.html b/web/src/app/shared/node-picker/node-picker.component.html new file mode 100644 index 0000000..c07a2d7 --- /dev/null +++ b/web/src/app/shared/node-picker/node-picker.component.html @@ -0,0 +1,51 @@ +
+ +
+ @for (n of nodes; track $index) { +
+ + {{ nodeName(n) || ('nodePicker.deletedNode' | translate) }} + {{ (n.nodeType === 'SCENE' ? 'nodePicker.scene' : 'nodePicker.chapter') | translate }} + + + +
+ } + @if (nodes.length === 0) { +

{{ 'nodePicker.empty' | translate }}

+ } +
+ +
+ + +
+ +
+ + +
+ +
diff --git a/web/src/app/shared/node-picker/node-picker.component.scss b/web/src/app/shared/node-picker/node-picker.component.scss new file mode 100644 index 0000000..f3c8bf4 --- /dev/null +++ b/web/src/app/shared/node-picker/node-picker.component.scss @@ -0,0 +1,68 @@ +.node-picker { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.node-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.node-chip { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + background: #1a1a2e; + border: 1px solid #2a2a3d; + border-radius: 8px; + + .node-icon { color: #9ca3af; flex: none; } + + .node-name { + flex: 1; + min-width: 0; + color: #e5e7eb; + font-size: 0.88rem; + } + + .node-type { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #9ca3af; + } + + button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.2rem; + background: transparent; + border: none; + border-radius: 4px; + color: #9ca3af; + cursor: pointer; + transition: color 0.15s, background 0.15s; + + &:hover:not(:disabled) { color: #fff; background: #2a2a3d; } + &:disabled { opacity: 0.3; cursor: default; } + } +} + +.empty-hint { + margin: 0; + font-size: 0.85rem; + font-style: italic; + color: #9ca3af; +} + +.add-row { + display: flex; + gap: 0.5rem; + align-items: center; + + select { flex: 1; min-width: 0; } +} diff --git a/web/src/app/shared/node-picker/node-picker.component.ts b/web/src/app/shared/node-picker/node-picker.component.ts new file mode 100644 index 0000000..48dd552 --- /dev/null +++ b/web/src/app/shared/node-picker/node-picker.component.ts @@ -0,0 +1,101 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { LucideAngularModule, Plus, X, ChevronUp, ChevronDown, BookOpen, MapPin } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; +import { Chapter, Scene, QuestNodeRef, NodeType } from '../../services/campaign.model'; + +/** + * Sélecteur de nœuds narratifs (Chapitres / Scènes) traversés par une quête. + * Émet une liste ORDONNÉE de QuestNodeRef ; l'ordre = position dans la liste + * (réindexé à chaque changement). Réutilise les chapitres / scènes déjà chargés + * par {@code loadCampaignTreeData} — aucun appel réseau ici. + */ +@Component({ + selector: 'app-node-picker', + imports: [FormsModule, LucideAngularModule, TranslatePipe], + templateUrl: './node-picker.component.html', + styleUrls: ['./node-picker.component.scss'] +}) +export class NodePickerComponent { + readonly Plus = Plus; + readonly X = X; + readonly ChevronUp = ChevronUp; + readonly ChevronDown = ChevronDown; + readonly BookOpen = BookOpen; + readonly MapPin = MapPin; + + @Input() nodes: QuestNodeRef[] = []; + @Input() chapters: Chapter[] = []; + @Input() scenes: Scene[] = []; + @Output() nodesChange = new EventEmitter(); + + selectedChapterId = ''; + selectedSceneId = ''; + + /** Chapitres pas encore rattachés (un même nœud ne se lie qu'une fois). */ + get availableChapters(): Chapter[] { + return this.chapters.filter(c => !!c.id && !this.isLinked('CHAPTER', c.id)); + } + + /** Scènes pas encore rattachées. */ + get availableScenes(): Scene[] { + return this.scenes.filter(s => !!s.id && !this.isLinked('SCENE', s.id)); + } + + private isLinked(type: NodeType, id: string): boolean { + return this.nodes.some(n => n.nodeType === type && n.nodeId === id); + } + + addChapter(): void { + if (!this.selectedChapterId) return; + this.emit([...this.nodes, { nodeType: 'CHAPTER', nodeId: this.selectedChapterId, order: this.nodes.length }]); + this.selectedChapterId = ''; + } + + addScene(): void { + if (!this.selectedSceneId) return; + this.emit([...this.nodes, { nodeType: 'SCENE', nodeId: this.selectedSceneId, order: this.nodes.length }]); + this.selectedSceneId = ''; + } + + remove(index: number): void { + this.emit(this.nodes.filter((_, i) => i !== index)); + } + + moveUp(index: number): void { + if (index <= 0) return; + const next = [...this.nodes]; + [next[index - 1], next[index]] = [next[index], next[index - 1]]; + this.emit(next); + } + + moveDown(index: number): void { + if (index >= this.nodes.length - 1) return; + const next = [...this.nodes]; + [next[index + 1], next[index]] = [next[index], next[index + 1]]; + this.emit(next); + } + + /** Réindexe {@code order} sur la position courante avant de propager. */ + private emit(nodes: QuestNodeRef[]): void { + this.nodesChange.emit(nodes.map((n, i) => ({ ...n, order: i }))); + } + + // ── Libellés ────────────────────────────────────────────────────────────── + /** Libellé d'un nœud lié ('' si l'entité a été supprimée → géré dans le template). */ + nodeName(n: QuestNodeRef): string { + if (n.nodeType === 'CHAPTER') { + return this.chapters.find(c => c.id === n.nodeId)?.name ?? ''; + } + const scene = this.scenes.find(s => s.id === n.nodeId); + if (!scene) return ''; + const chapter = this.chapters.find(c => c.id === scene.chapterId); + return chapter ? `${chapter.name} › ${scene.name}` : scene.name; + } + + /** Option du menu déroulant des scènes : préfixée du chapitre pour lever l'ambiguïté. */ + sceneOptionLabel(s: Scene): string { + const chapter = this.chapters.find(c => c.id === s.chapterId); + return chapter ? `${chapter.name} › ${s.name}` : s.name; + } +} diff --git a/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts b/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts index 6cfdd67..d5fa710 100644 --- a/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts +++ b/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts @@ -3,7 +3,13 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular'; import { TranslatePipe } from '@ngx-translate/core'; -import { Chapter, Prerequisite } from '../../services/campaign.model'; +import { Prerequisite } from '../../services/campaign.model'; + +/** Cible candidate pour un prérequis QUEST_COMPLETED (Chapter HUB hérité ou Quest). */ +export interface PrerequisiteTarget { + id?: string; + name: string; +} /** * Éditeur des prérequis (conditions de déblocage) d'une quête. @@ -22,8 +28,8 @@ export class PrerequisiteEditorComponent { /** Liste courante. */ @Input() prerequisites: Prerequisite[] = []; - /** Quêtes candidates pour QUEST_COMPLETED (typiquement les chapitres frères du Hub). */ - @Input() availableQuests: Chapter[] = []; + /** Quêtes candidates pour QUEST_COMPLETED (Chapitres HUB hérités ou Quests). */ + @Input() availableQuests: PrerequisiteTarget[] = []; /** Flags déjà connus de la campagne (autocomplete pour FLAG_SET). */ @Input() availableFlags: string[] = []; diff --git a/web/src/app/shared/readiness-panel/readiness-panel.component.html b/web/src/app/shared/readiness-panel/readiness-panel.component.html new file mode 100644 index 0000000..d2a47ca --- /dev/null +++ b/web/src/app/shared/readiness-panel/readiness-panel.component.html @@ -0,0 +1,50 @@ +@if (assessment) { +
+ + + @if (!collapsed) { + @if (isReady) { +

{{ 'readiness.allSet' | translate }}

+ } @else { + + } + } +
+} diff --git a/web/src/app/shared/readiness-panel/readiness-panel.component.scss b/web/src/app/shared/readiness-panel/readiness-panel.component.scss new file mode 100644 index 0000000..deea8bb --- /dev/null +++ b/web/src/app/shared/readiness-panel/readiness-panel.component.scss @@ -0,0 +1,135 @@ +.readiness-panel { + margin: 0 0 1.25rem; + background: #0d1117; + border: 1px solid #1f2937; + border-radius: 10px; + overflow: hidden; + + &.ready { border-color: #14532d; } +} + +.readiness-header { + display: flex; + align-items: center; + gap: 0.55rem; + width: 100%; + padding: 0.7rem 0.9rem; + background: transparent; + border: none; + color: #e5e7eb; + cursor: pointer; + text-align: left; + + &:hover { background: #111827; } + + .ready-icon { color: #4ade80; } + .warn-icon { color: #fb923c; } +} + +.readiness-title { + font-weight: 600; + font-size: 0.92rem; +} + +.status-badge { + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + + &.status-DRAFT { background: #3a1d1d; color: #fca5a5; } + &.status-PLAYABLE { background: #3a2e1a; color: #fcd34d; } + &.status-POLISHED { background: #14311f; color: #86efac; } +} + +.counts { + display: inline-flex; + gap: 0.35rem; + margin-left: auto; +} + +.count-chip { + padding: 0.1rem 0.45rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 500; + + &.blocking { background: #3a1d1d; color: #fca5a5; } + &.recommended { background: #3a2e1a; color: #fcd34d; } +} + +.ready-msg { + margin: 0; + padding: 0 0.9rem 0.9rem; + color: #86efac; + font-size: 0.85rem; +} + +.gap-list { + list-style: none; + margin: 0; + padding: 0 0.6rem 0.6rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.gap-card { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.55rem 0.7rem; + background: #111827; + border: 1px solid #1f2937; + border-radius: 8px; + + .sev-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + &.sev-BLOCKING .sev-dot { background: #ef4444; } + &.sev-RECOMMENDED .sev-dot { background: #fb923c; } + &.sev-OPTIONAL .sev-dot { background: #6b7280; } +} + +.gap-body { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.gap-message { + color: #e5e7eb; + font-size: 0.83rem; + line-height: 1.25; +} + +.gap-entity { + color: #9ca3af; + font-size: 0.74rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gap-cta { + display: inline-flex; + align-items: center; + gap: 0.2rem; + flex-shrink: 0; + padding: 0.3rem 0.6rem; + background: #1f1d3a; + border: 1px solid #3a3658; + border-radius: 6px; + color: #c7d2fe; + font-size: 0.76rem; + text-decoration: none; + transition: background 0.15s, color 0.15s; + + &:hover { background: #2c2952; color: #fff; } +} diff --git a/web/src/app/shared/readiness-panel/readiness-panel.component.ts b/web/src/app/shared/readiness-panel/readiness-panel.component.ts new file mode 100644 index 0000000..5d78237 --- /dev/null +++ b/web/src/app/shared/readiness-panel/readiness-panel.component.ts @@ -0,0 +1,83 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; +import { LucideAngularModule, ChevronDown, ChevronRight, CheckCircle2, AlertTriangle, ArrowRight } from 'lucide-angular'; +import { ReadinessService } from '../../services/readiness.service'; +import { CampaignReadinessAssessment, ReadinessGap } from '../../services/readiness.model'; +import { gapAction } from '../../campaigns/gap-action.helper'; + +/** + * Panneau « Prochaines étapes » (Pilier B — guidage co-MJ). Affiche le statut de + * préparation agrégé d'une campagne et la liste des manques à combler, chacun + * cliquable vers l'éditeur concerné. Purement indicatif (ne bloque rien). + * + *

Peut recevoir un {@link CampaignReadinessAssessment} déjà chargé via + * {@code [assessment]} (évite un second appel quand le parent l'a déjà) ; sinon + * il le charge lui-même à partir de {@code [campaignId]}.

+ */ +@Component({ + selector: 'app-readiness-panel', + standalone: true, + imports: [RouterLink, TranslatePipe, LucideAngularModule], + templateUrl: './readiness-panel.component.html', + styleUrls: ['./readiness-panel.component.scss'] +}) +export class ReadinessPanelComponent implements OnInit { + + @Input() campaignId = ''; + @Input() assessment: CampaignReadinessAssessment | null = null; + + loading = false; + collapsed = false; + + readonly ChevronDown = ChevronDown; + readonly ChevronRight = ChevronRight; + readonly CheckCircle2 = CheckCircle2; + readonly AlertTriangle = AlertTriangle; + readonly ArrowRight = ArrowRight; + + private static readonly STORAGE_KEY = 'loremind.readiness.collapsed'; + + constructor(private readinessService: ReadinessService) {} + + ngOnInit(): void { + this.collapsed = localStorage.getItem(ReadinessPanelComponent.STORAGE_KEY) === '1'; + if (!this.assessment && this.campaignId) { + this.fetch(); + } + } + + private fetch(): void { + this.loading = true; + this.readinessService.getReadiness(this.campaignId).subscribe({ + next: a => { this.assessment = a; this.loading = false; }, + error: () => { this.loading = false; } + }); + } + + toggle(): void { + this.collapsed = !this.collapsed; + localStorage.setItem(ReadinessPanelComponent.STORAGE_KEY, this.collapsed ? '1' : '0'); + } + + get blockingCount(): number { + return this.assessment?.counts?.['BLOCKING'] ?? 0; + } + + get recommendedCount(): number { + return this.assessment?.counts?.['RECOMMENDED'] ?? 0; + } + + get isReady(): boolean { + return !!this.assessment && this.assessment.gaps.length === 0; + } + + /** Lien profond « réparateur » : route + query params (outil auto-ouvert sur la cible). */ + gapLink(gap: ReadinessGap): string[] { + return gapAction(gap, this.campaignId).link; + } + + gapQuery(gap: ReadinessGap): Record | null { + return gapAction(gap, this.campaignId).queryParams ?? null; + } +} diff --git a/web/src/app/shared/scene-draft-panel/scene-draft-panel.component.html b/web/src/app/shared/scene-draft-panel/scene-draft-panel.component.html new file mode 100644 index 0000000..129d5c2 --- /dev/null +++ b/web/src/app/shared/scene-draft-panel/scene-draft-panel.component.html @@ -0,0 +1,60 @@ +
+ + + @if (open) { +
+

{{ 'sceneDraft.hint' | translate }}

+ +
+ +
+ + +
+
+ + @if (error) { +

{{ error }}

+ } + + @if (generated && drafts.length === 0 && !loading) { +

{{ 'sceneDraft.empty' | translate }}

+ } + + @if (drafts.length > 0) { +
    + @for (d of drafts; track $index) { +
  • + + @if (d.description) {

    {{ d.description }}

    } + @if (d.playerNarration) {

    {{ d.playerNarration }}

    } +
  • + } +
+ +
+ +
+ } +
+ } +
diff --git a/web/src/app/shared/scene-draft-panel/scene-draft-panel.component.scss b/web/src/app/shared/scene-draft-panel/scene-draft-panel.component.scss new file mode 100644 index 0000000..b1cc315 --- /dev/null +++ b/web/src/app/shared/scene-draft-panel/scene-draft-panel.component.scss @@ -0,0 +1,145 @@ +.scene-draft { + border: 1px solid #24303a; + border-radius: 10px; + background: #101820; + overflow: hidden; +} + +.draft-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.6rem 0.85rem; + background: transparent; + border: none; + color: #b9e0ff; + cursor: pointer; + text-align: left; + + &:hover { background: #14202b; } + + .spark { color: #7fc8ff; } + .draft-title { font-weight: 600; font-size: 0.9rem; } +} + +.draft-body { + padding: 0.2rem 0.85rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.draft-hint { + margin: 0; + color: #9ca3af; + font-size: 0.8rem; +} + +.draft-prompt { + display: flex; + flex-direction: column; + gap: 0.45rem; + + textarea { width: 100%; box-sizing: border-box; resize: vertical; min-height: 2.4rem; } +} + +.draft-controls { + display: flex; + align-items: center; + gap: 0.6rem; + + .count-label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: #9ca3af; + font-size: 0.82rem; + select { padding: 0.25rem 0.4rem; } + } +} + +.btn-generate, +.btn-apply { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.45rem 0.8rem; + border-radius: 7px; + border: 1px solid #2c5372; + background: #16283a; + color: #b9e0ff; + cursor: pointer; + font-size: 0.82rem; + white-space: nowrap; + transition: background 0.15s, color 0.15s; + + &:hover:not(:disabled) { background: #1d3651; color: #fff; } + &:disabled { opacity: 0.5; cursor: default; } +} + +.btn-apply { + border-color: #2f6b46; + background: #163326; + color: #a7f3d0; + margin-left: auto; + &:hover:not(:disabled) { background: #1c4531; color: #fff; } +} + +.spin { animation: draft-spin 0.9s linear infinite; } +@keyframes draft-spin { to { transform: rotate(360deg); } } + +.draft-error { margin: 0; color: #fca5a5; font-size: 0.82rem; } +.draft-empty { margin: 0; color: #9ca3af; font-style: italic; font-size: 0.82rem; } + +.draft-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.draft-card { + padding: 0.55rem 0.7rem; + background: #13202b; + border: 1px solid #223341; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 0.25rem; + + &.rejected { opacity: 0.5; } +} + +.draft-check { + display: flex; + align-items: center; + gap: 0.45rem; + cursor: pointer; + + input { accent-color: #7fc8ff; } + .draft-name { font-weight: 600; color: #e5e7eb; font-size: 0.85rem; } +} + +.draft-desc { + margin: 0; + color: #cbd5e1; + font-size: 0.82rem; + line-height: 1.3; +} + +.draft-narration { + margin: 0; + color: #94a3b8; + font-size: 0.8rem; + line-height: 1.35; + font-style: italic; + white-space: pre-wrap; +} + +.draft-actions { + display: flex; + justify-content: flex-end; +} diff --git a/web/src/app/shared/scene-draft-panel/scene-draft-panel.component.ts b/web/src/app/shared/scene-draft-panel/scene-draft-panel.component.ts new file mode 100644 index 0000000..c3361f8 --- /dev/null +++ b/web/src/app/shared/scene-draft-panel/scene-draft-panel.component.ts @@ -0,0 +1,119 @@ +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { LucideAngularModule, Sparkles, RefreshCw, Check, ChevronDown, ChevronRight } from 'lucide-angular'; +import { SceneDraftService } from '../../services/scene-draft.service'; +import { SceneDraft } from '../../services/scene-draft.model'; + +interface ReviewDraft extends SceneDraft { + accepted: boolean; +} + +/** + * Panneau « Générer des scènes » (Pilier A — capacité « create »). Le co-MJ ébauche + * plusieurs scènes pour un chapitre ; l'utilisateur révise/coche, puis les scènes retenues + * sont CRÉÉES dans le chapitre (contrairement à l'étoffage de champs, l'application persiste). + * + *

Émet {@code created} avec le nombre de scènes créées pour que le parent puisse + * naviguer (ex. vers le graphe du chapitre).

+ */ +@Component({ + selector: 'app-scene-draft-panel', + standalone: true, + imports: [FormsModule, TranslatePipe, LucideAngularModule], + templateUrl: './scene-draft-panel.component.html', + styleUrls: ['./scene-draft-panel.component.scss'] +}) +export class SceneDraftPanelComponent implements OnChanges { + + @Input() chapterId = ''; + @Input() campaignId = ''; + /** Déplie le panneau à l'arrivée (bouton « Corriger » du guidage sur un chapitre vide). */ + @Input() startOpen = false; + /** Nombre de scènes créées, pour que le parent réagisse (navigation/refresh). */ + @Output() created = new EventEmitter(); + + open = false; + instruction = ''; + count = 4; + loading = false; + applying = false; + generated = false; + error = ''; + drafts: ReviewDraft[] = []; + + readonly countOptions = [3, 4, 5, 6]; + + readonly Sparkles = Sparkles; + readonly RefreshCw = RefreshCw; + readonly Check = Check; + readonly ChevronDown = ChevronDown; + readonly ChevronRight = ChevronRight; + + constructor(private draftService: SceneDraftService, private translate: TranslateService) {} + + /** Repart d'un état vierge si le chapitre ciblé change (composant réutilisé). */ + ngOnChanges(changes: SimpleChanges): void { + if (changes['startOpen'] && this.startOpen) { + this.open = true; + } + if (changes['chapterId'] && !changes['chapterId'].firstChange) { + this.open = false; + this.instruction = ''; + this.loading = false; + this.applying = false; + this.generated = false; + this.error = ''; + this.drafts = []; + } + } + + toggle(): void { + this.open = !this.open; + } + + generate(): void { + if (!this.chapterId || this.loading) return; + this.loading = true; + this.error = ''; + this.generated = false; + this.drafts = []; + this.draftService.generate(this.chapterId, this.campaignId, this.instruction.trim(), this.count).subscribe({ + next: proposal => { + this.drafts = (proposal.scenes ?? []).map(s => ({ ...s, accepted: true })); + this.generated = true; + this.loading = false; + }, + error: () => { + this.error = this.translate.instant('sceneDraft.error'); + this.loading = false; + } + }); + } + + get acceptedCount(): number { + return this.drafts.filter(d => d.accepted).length; + } + + apply(): void { + const accepted: SceneDraft[] = this.drafts + .filter(d => d.accepted) + .map(d => ({ name: d.name, description: d.description, playerNarration: d.playerNarration })); + if (accepted.length === 0 || this.applying) return; + this.applying = true; + this.error = ''; + this.draftService.apply(this.chapterId, { chapterId: this.chapterId, scenes: accepted }).subscribe({ + next: createdScenes => { + this.applying = false; + this.drafts = []; + this.generated = false; + this.open = false; + this.created.emit(createdScenes.length); + }, + error: () => { + this.applying = false; + this.error = this.translate.instant('sceneDraft.applyError'); + } + }); + } +} diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts index 46e3b44..39bf7ea 100644 --- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts +++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts @@ -217,7 +217,7 @@ export class SecondarySidebarComponent implements OnDestroy { */ runCreateAction(event: Event, action: TreeCreateAction): void { event.stopPropagation(); - this.router.navigate([action.route]); + this.router.navigate([action.route], action.queryParams ? { queryParams: action.queryParams } : {}); } /** True si le noeud a au moins un vrai enfant (utile pour le chevron). */ diff --git a/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.html b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.html index b9a42ff..e2894c3 100644 --- a/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.html +++ b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.html @@ -19,6 +19,13 @@ @if (sidebar.iconFor(item); as icon) { } + + @if (item.statusDot) { + + } {{ item.label }} @if (!item.isAction && item.meta) { {{ item.meta }} diff --git a/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.scss b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.scss index 8a33ef9..5f992b1 100644 --- a/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.scss +++ b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.scss @@ -47,6 +47,18 @@ color: #6b7280; flex-shrink: 0; } + + // Pastille de readiness (guidage) : reste collée au libellé ; si un compteur + // `meta` est présent, il file à droite (margin-left:auto) et la pastille reste ici. + .status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + + &.blocking { background: #ef4444; } + &.recommended { background: #fb923c; } + } } .item-icon { diff --git a/web/src/app/shared/session-prep-panel/session-prep-panel.component.html b/web/src/app/shared/session-prep-panel/session-prep-panel.component.html new file mode 100644 index 0000000..fb1b680 --- /dev/null +++ b/web/src/app/shared/session-prep-panel/session-prep-panel.component.html @@ -0,0 +1,130 @@ +@if (report) { +
+ + + @if (report.lastSession; as last) { +

+ + {{ (last.active ? 'sessionPrep.lastSessionActive' : 'sessionPrep.lastSessionEnded') | translate:{ name: last.name } }} +

+ } + + @if (allQuiet) { +

+ + {{ 'sessionPrep.allQuiet' | translate }} +

+ } + + + @if (report.questsInProgress.length > 0 || report.questsAvailable.length > 0) { +
+

{{ 'sessionPrep.positionTitle' | translate }}

+ @if (report.questsInProgress.length > 0) { +
+ {{ 'sessionPrep.inProgress' | translate }} + @for (q of report.questsInProgress; track q.id) { + + {{ q.name }} + + + } +
+ } + @if (report.questsAvailable.length > 0) { +
+ {{ 'sessionPrep.available' | translate }} + @for (q of report.questsAvailable; track q.id) { + + {{ q.name }} + + + + } +
+ } +
+ } + + + @if (report.questsCompleted.length > 0) { +
+ {{ 'sessionPrep.completed' | translate }} + @for (q of report.questsCompleted; track q.id) { + + {{ q.name }} + + + } +
+ } + + + @if (report.hotspots.length > 0) { +
+

{{ 'sessionPrep.hotspotsTitle' | translate }}

+
+ @for (node of report.hotspots; track node.nodeType + node.id) { + + {{ node.name }} + {{ ('sessionPrep.nodeKind.' + node.nodeType) | translate }} + + } +
+
+ } + + + @if (report.gaps.length > 0) { +
+

{{ 'sessionPrep.gapsTitle' | translate }}

+ +
+ } + @if (report.otherGapCount > 0) { +

+ {{ 'sessionPrep.otherGaps' | translate:{ n: report.otherGapCount } }} +

+ } + + + @if (report.clocks.length > 0) { +
+

{{ 'sessionPrep.clocksTitle' | translate }}

+
+ @for (clock of report.clocks; track clock.id) { + + {{ clock.name }} — {{ clock.filled }}/{{ clock.segments }} + @if (clock.frontName) { ({{ clock.frontName }}) } + @if (isFull(clock)) { {{ 'sessionPrep.clockFull' | translate }} } + @else if (isNearlyFull(clock)) { {{ 'sessionPrep.clockNearly' | translate }} } + + } +
+
+ } + +
+} diff --git a/web/src/app/shared/session-prep-panel/session-prep-panel.component.scss b/web/src/app/shared/session-prep-panel/session-prep-panel.component.scss new file mode 100644 index 0000000..89440f4 --- /dev/null +++ b/web/src/app/shared/session-prep-panel/session-prep-panel.component.scss @@ -0,0 +1,180 @@ +.prep-panel { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.prep-last { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + color: #9ca3af; + font-size: 0.85rem; +} + +.prep-quiet { + display: flex; + align-items: center; + gap: 0.45rem; + margin: 0; + color: #86efac; + font-size: 0.88rem; +} + +.prep-section { + display: flex; + flex-direction: column; + gap: 0.45rem; + + h3 { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + font-size: 0.82rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #9ca3af; + + &.warn { color: #fb923c; } + } +} + +.prep-chip-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} + +.prep-chip-label { + color: #6b7280; + font-size: 0.78rem; +} + +// Groupe chip + actions : le lien ouvre la quête, les petits boutons pilotent +// la progression (▶ commencer, ✓ terminer, ↺ rouvrir). +.prep-chip-group { + display: inline-flex; + align-items: center; + gap: 2px; + + .chip-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: #1a1a2e; + border: 1px solid #2a2a3d; + border-radius: 50%; + color: #9ca3af; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; + + &:hover { color: #fff; border-color: #6c63ff; background: #24243a; } + } +} + +.prep-completed .prep-chip-label { color: #4b5563; } + +.prep-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.65rem; + border-radius: 999px; + font-size: 0.82rem; + text-decoration: none; + border: 1px solid transparent; + transition: filter 0.15s; + + &:hover { filter: brightness(1.25); } + + &.in-progress { background: #1e2f45; border-color: #3c6ea5; color: #93c5fd; } + &.available { background: #14311f; border-color: #2f6b46; color: #86efac; } + &.node { background: #1f1d3a; border-color: #3a3658; color: #cfcbe8; } + &.done { background: #16161f; border-color: #2a2a3d; color: #6b7280; text-decoration: line-through; } + + .node-kind { + font-size: 0.68rem; + color: #8b8595; + text-transform: uppercase; + } +} + +.prep-gap-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.prep-gap { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.7rem; + background: #14141f; + border: 1px solid #2a2a3d; + border-left: 3px solid #fb923c; + border-radius: 8px; + + &.blocking { border-left-color: #ef4444; } + + .gap-msg { + flex: 1; + min-width: 0; + color: #e5e7eb; + font-size: 0.83rem; + line-height: 1.3; + } + + .gap-cta { + display: inline-flex; + align-items: center; + gap: 0.2rem; + flex-shrink: 0; + padding: 0.25rem 0.55rem; + background: #1f1d3a; + border: 1px solid #3a3658; + border-radius: 6px; + color: #c7d2fe; + font-size: 0.75rem; + text-decoration: none; + + &:hover { background: #2c2952; color: #fff; } + } +} + +.prep-other { + margin: 0; + font-size: 0.78rem; + + a { color: #6b7280; text-decoration: none; } + a:hover { color: #9ca3af; text-decoration: underline; } +} + +.prep-clock { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.65rem; + border-radius: 999px; + background: #201b3a; + border: 1px solid #3a3068; + color: #b9a8ff; + font-size: 0.82rem; + + .clock-front { color: #8b8595; font-size: 0.75rem; } + + &.nearly { border-color: #b48a4f; color: #fcd34d; background: #3a301a; } + &.full { border-color: #b4554f; color: #fca5a5; background: #3a1d1d; } + + strong { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; } +} diff --git a/web/src/app/shared/session-prep-panel/session-prep-panel.component.ts b/web/src/app/shared/session-prep-panel/session-prep-panel.component.ts new file mode 100644 index 0000000..a9b06fc --- /dev/null +++ b/web/src/app/shared/session-prep-panel/session-prep-panel.component.ts @@ -0,0 +1,107 @@ +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; +import { LucideAngularModule, CalendarCheck, Compass, MapPin, AlertTriangle, Timer, CheckCircle2, ArrowRight, Play, Check, RotateCcw } from 'lucide-angular'; +import { SessionPrepService } from '../../services/session-prep.service'; +import { QuestService } from '../../services/quest.service'; +import { SessionPrepReport, PrepClock, PrepNode, PrepQuest } from '../../services/session-prep.model'; +import { ReadinessGap } from '../../services/readiness.model'; +import { gapAction } from '../../campaigns/gap-action.helper'; + +/** + * Panneau « Préparer la prochaine séance » (Phase 3 co-MJ) sur la page d'une Partie. + * Croise la position des joueurs (quêtes actives, dernière séance), le contenu probable, + * les manques du guidage CIBLÉS sur ce contenu, et les horloges en mouvement. + * Read-only : chaque élément deep-linke vers l'éditeur / la fiche concernée. + */ +@Component({ + selector: 'app-session-prep-panel', + standalone: true, + imports: [RouterLink, TranslatePipe, LucideAngularModule], + templateUrl: './session-prep-panel.component.html', + styleUrls: ['./session-prep-panel.component.scss'] +}) +export class SessionPrepPanelComponent implements OnChanges { + + @Input() playthroughId = ''; + @Input() campaignId = ''; + + report: SessionPrepReport | null = null; + loading = false; + + readonly CalendarCheck = CalendarCheck; + readonly Compass = Compass; + readonly MapPin = MapPin; + readonly AlertTriangle = AlertTriangle; + readonly Timer = Timer; + readonly CheckCircle2 = CheckCircle2; + readonly ArrowRight = ArrowRight; + readonly Play = Play; + readonly Check = Check; + readonly RotateCcw = RotateCcw; + + constructor(private prepService: SessionPrepService, + private questService: QuestService) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes['playthroughId'] && this.playthroughId) { + this.load(); + } + } + + private load(): void { + this.loading = true; + this.report = null; + this.prepService.getPrep(this.playthroughId).subscribe({ + next: r => { this.report = r; this.loading = false; }, + error: () => { this.loading = false; } + }); + } + + /** Rien à signaler : ni quête active, ni manque ciblé, ni horloge en mouvement. */ + get allQuiet(): boolean { + const r = this.report; + return !!r && r.questsInProgress.length === 0 && r.questsAvailable.length === 0 + && r.gaps.length === 0 && r.clocks.length === 0; + } + + /** Lien profond vers un nœud probable (chapitre ou scène). */ + nodeLink(node: PrepNode): string[] { + const base = ['/campaigns', this.campaignId, 'arcs', node.arcId ?? '']; + return node.nodeType === 'SCENE' + ? [...base, 'chapters', node.chapterId ?? '', 'scenes', node.id] + : [...base, 'chapters', node.id]; + } + + /** Lien profond « réparateur » — mapping partagé avec le panneau readiness du hub. */ + gapLink(gap: ReadinessGap): string[] { + return gapAction(gap, this.campaignId).link; + } + + gapQuery(gap: ReadinessGap): Record | null { + return gapAction(gap, this.campaignId).queryParams ?? null; + } + + // ─────────────── Progression des quêtes (pilotée depuis les chips) ─────────────── + // C'est ICI que le MJ tient à jour « où en sont les joueurs » : Disponible → ▶ En + // cours → ✓ Terminée (elle disparaît des pistes), ↺ pour rouvrir en cas d'erreur. + + private setProgression(quest: PrepQuest, status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED'): void { + this.questService.setProgression(this.playthroughId, quest.id, status).subscribe({ + next: () => this.load(), // recharge le rapport : listes + manques ciblés à jour + error: () => console.error('Erreur lors de la mise à jour de la progression') + }); + } + + startQuest(quest: PrepQuest): void { this.setProgression(quest, 'IN_PROGRESS'); } + completeQuest(quest: PrepQuest): void { this.setProgression(quest, 'COMPLETED'); } + reopenQuest(quest: PrepQuest): void { this.setProgression(quest, 'NOT_STARTED'); } + + isNearlyFull(clock: PrepClock): boolean { + return clock.segments - clock.filled === 1; + } + + isFull(clock: PrepClock): boolean { + return clock.filled >= clock.segments; + } +} diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index 2b1a4ac..8599e53 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -378,7 +378,9 @@ "removeLink": "Remove link", "increment": "One more", "decrement": "One less", - "noMatch": "No matching enemy" + "noMatch": "No matching enemy", + "broken": "Missing reference", + "brokenTitle": "This scene references a deleted bestiary entry (enemy deleted then recreated?). Click × to remove the dead reference." }, "personaView": { "empty": "This sheet is still empty." @@ -508,7 +510,11 @@ "chatSuggestion3": "Suggest a two-act resolution", "deleteTitle": "Delete arc", "deleteMessage": "Delete the arc \"{{name}}\"?", - "irreversible": "This action is irreversible." + "irreversible": "This action is irreversible.", + "optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.", + "themesStakesSectionTitle": "Themes and stakes", + "gmNotesSectionTitle": "GM notes", + "rewardsResolutionSectionTitle": "Rewards and resolution" }, "arcView": { "subtitleHub": "Hub arc (non-linear quests)", @@ -537,7 +543,10 @@ "deleteCascade": "This action will also delete: {{parts}}.", "irreversible": "This action is irreversible.", "deleteConfirmTitle": "Delete arc", - "deleteConfirmMessage": "Delete the arc \"{{name}}\"?" + "deleteConfirmMessage": "Delete the arc \"{{name}}\"?", + "questsTitle": "Quests in this arc", + "questsEmpty": "No quests yet. Add one to populate this hub.", + "createQuest": "New quest" }, "campaigns": { "heroTitle": "Your Campaigns", @@ -625,7 +634,13 @@ "playthroughsPlural": "{{n}} playthroughs (with their PCs, sessions, facts)", "alsoDeletes": "Will also be deleted: {{items}}.", "irreversible": "This action is irreversible." - } + }, + "quickScene": "Scene", + "quickSceneFirst": "Add a scene", + "organizeInArcs": "or organize into arcs", + "defaultArcName": "Main arc", + "defaultChapterName": "Chapter 1", + "flatModeHint": "Simple mode: your scenes are in the left panel. Add one, or organize the campaign into arcs." }, "campaignImport": { "pageTitle": "Import a campaign", @@ -726,7 +741,11 @@ "chatSuggestion3": "Suggest a striking opening scene", "deleteTitle": "Delete chapter", "deleteMessage": "Delete the chapter \"{{name}}\"?", - "irreversible": "This action is irreversible." + "irreversible": "This action is irreversible.", + "optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.", + "gmNotesSectionTitle": "GM notes", + "objectivesStakesSectionTitle": "Objectives and stakes", + "quest": "Quest" }, "chapterGraph": { "chapter": "Chapter", @@ -734,8 +753,31 @@ "subtitle": "Flowchart of scenes and their narrative branches", "back": "Back to chapter", "empty": "This chapter has no scenes. Create some to see the map appear.", - "hint": "💡 Click a scene to open it, or drag it to rearrange the map. Scenes not connected to the entry point (scene of order 1) appear at the bottom.", - "allCampaigns": "All campaigns" + "hint": "💡 Click a scene to open it, drag it to move it. Drag the dot under a node onto another to create a link; click a link to edit or delete it. Wheel = zoom, drag the background = pan the view.", + "allCampaigns": "All campaigns", + "addScene": "Add a scene", + "newSceneName": "New scene", + "titleFlat": "Scene map", + "backFlat": "Back to campaign", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "fit": "Fit to content", + "linkEditor": { + "label": "Choice label", + "labelPlaceholder": "e.g. \"Follow the trail\"", + "condition": "Condition (GM notes)", + "conditionPlaceholder": "e.g. \"if the PCs have the key\"", + "type": "Link type", + "delete": "Delete link", + "close": "Close", + "kind": { + "EXIT": "Exit", + "CLUE": "Clue", + "LEAD": "Lead" + } + }, + "rename": "Rename scene", + "renamePlaceholder": "Scene name" }, "chapterView": { "chapter": "Chapter", @@ -941,8 +983,8 @@ "aiAssistantTitle": "Open the AI Assistant to discuss this scene", "illustrationsLabel": "Illustrations", "illustrationsHint": "NPC portraits, visual mood, evocative scenes...", - "battlemapLabel": "Battlemap (Foundry export)", - "battlemapHint": "Battle map to export to Foundry: media (image/video) + Universal VTT .json/.dd2vtt file (Dungeon Alchemist, Dungeondraft...). Not displayed in the app.", + "battlemapLabel": "Battlemaps (Foundry export)", + "battlemapHint": "Battle maps to export to Foundry: media (image/video) + Universal VTT .json/.dd2vtt file (Dungeon Alchemist, Dungeondraft...). Add several maps for variants of the same scene (day/night, floors...). Not displayed in the app.", "battlemapMedia": "Media (image / video)", "battlemapData": "Data (.json / .dd2vtt)", "battlemapChoose": "Choose a file", @@ -951,7 +993,7 @@ "battlemapRemove": "Remove", "nameLabel": "Scene title *", "namePlaceholder": "E.g.: Arrival at the village", - "descriptionLabel": "Short description *", + "descriptionLabel": "Short description", "descriptionPlaceholder": "One or two sentences summarizing what happens...", "iconLabel": "Icon", "contextSectionTitle": "Context and mood", @@ -1007,7 +1049,15 @@ "battlemapSourceDD": "DungeonDraft (.dd2vtt)", "battlemapDd2vttSlot": ".dd2vtt file", "battlemapDd2vttHint": "The .dd2vtt contains the map AND the walls/lights; the embedded image is extracted automatically for the Foundry export.", - "battlemapDd2vttNoImage": "No image found in this .dd2vtt — the exported map will have no background." + "battlemapDd2vttNoImage": "No image found in this .dd2vtt — the exported map will have no background.", + "optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.", + "nodeTypeLabel": "Node type", + "nodeTypeHint": "Level 2: typing the scene (location, encounter, NPC…) colors the node in the graph view. Optional.", + "branchKindLabel": "Link type", + "branchTargetBroken": "Destination not found (deleted scene): pick a new destination or remove this branch.", + "battlemapAdd": "Add a map", + "battlemapLabelPlaceholder": "Label (e.g.: Day, Night, Floor 1…)", + "battlemapRemoveMap": "Remove this map" }, "sceneView": { "subtitle": "Scene", @@ -1411,6 +1461,20 @@ "deleteEntryConfirm": { "title": "Delete this entry?", "message": "This log entry will be permanently deleted." + }, + "pinned": { + "label": "Current scene", + "open": "Open the scene (new tab)", + "unpin": "Unpin", + "showNarration": "Show player narration", + "hideNarration": "Hide narration" + }, + "recap": { + "button": "“Previously…” recap", + "loading": "Writing the recap…", + "title": "Previously ({{name}})", + "addToJournal": "Add to journal", + "error": "Recap failed. Is the Brain (AI) running?" } }, "sessionReferencePanel": { @@ -1423,7 +1487,12 @@ "playerCharacters": "Player characters", "nonPlayerCharacters": "Non-player characters", "emptyCharacters": "No characters in this campaign.", - "emptyScenes": "No narrative arc yet. Build your campaign scenario to find it here." + "emptyScenes": "No narrative arc yet. Build your campaign scenario to find it here.", + "tabPlay": "Play", + "playClocks": "Clocks", + "playFacts": "Facts", + "playNoPlaythrough": "This session is not attached to any playthrough.", + "pinScene": "Pin as current scene" }, "sessionDicePanel": { "count": "Count", @@ -1521,7 +1590,9 @@ "deleteCascade": "This action will also delete: {{parts}}.", "irreversible": "This action is irreversible.", "deleteTitle": "Delete playthrough", - "deleteMessage": "Delete \"{{name}}\"?" + "deleteMessage": "Delete \"{{name}}\"?", + "clocksTitle": "Progress clocks", + "prepTitle": "Prepare the next session" }, "playthroughFlagsPage": { "title": "Facts — {{name}}", @@ -1546,7 +1617,9 @@ "allCampaigns": "All campaigns", "sectionCharacters": "Characters", "sectionNarration": "Narration", - "sectionTools": "Tools" + "sectionTools": "Tools", + "scenesGroup": "Scenes", + "quests": "Quests" }, "services": { "importFailed": "Import failed.", @@ -1563,5 +1636,244 @@ "zoomOut": "Zoom out", "dragHint": "Drag to reframe", "remove": "Remove this image" + }, + "questList": { + "title": "Quests", + "create": "New quest", + "hint": "Quests are orthogonal to the structure: they carry objectives and unlock conditions, and can span several scenes.", + "empty": "No quests yet.", + "conditional": "Unlock conditions", + "deleteTitle": "Delete quest", + "deleteMessage": "Delete the quest \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "questEdit": { + "newTitle": "New quest", + "fallbackTitle": "Quest", + "subtitle": "Quest", + "optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.", + "nameLabel": "Quest title *", + "namePlaceholder": "E.g.: Save the missing merchant", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Describe the objective and context of the quest...", + "iconLabel": "Icon", + "unlockSectionTitle": "Unlock conditions", + "unlockHint": "All conditions must be met (AND) for the quest to unlock in a Playthrough. Empty = available from the start. Progression is managed in the Playthrough screen.", + "gmNotesSectionTitle": "GM notes", + "gmNotesPlaceholder": "Private notes about the quest, twists, secrets...", + "objectivesSectionTitle": "Objectives and stakes", + "playerObjectivesLabel": "Player objectives", + "playerObjectivesPlaceholder": "What must the players accomplish?", + "narrativeStakesLabel": "Narrative stakes", + "narrativeStakesPlaceholder": "What are the dramatic stakes?", + "deleteTitle": "Delete quest", + "deleteMessage": "Delete the quest \"{{name}}\"?", + "irreversible": "This action is irreversible.", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Portraits, moods and notable locations of the quest.", + "nodesSectionTitle": "Narrative nodes traversed", + "nodesHint": "OPTIONAL — only use this if the quest should TRAVERSE existing content (“the quest passes through these places”). These are links: removing a node deletes nothing. Without nodes, the quest automatically gets its own scene space (created on the fly, with its map).", + "relatedPagesLabel": "Linked Lore pages", + "relatedPagesHint": "Link this quest to NPCs, places or Lore elements that appear in it.", + "noLoreHint": "💡 This campaign is not linked to any universe. Link it to a Lore from the campaign screen to attach Lore pages to this quest." + }, + "nodePicker": { + "chapter": "Chapter", + "scene": "Scene", + "selectChapter": "Add a chapter...", + "selectScene": "Add a scene...", + "addChapter": "Add", + "addScene": "Add", + "empty": "No node attached yet. This quest isn't linked to any chapter or scene.", + "deletedNode": "(deleted node)", + "moveUp": "Move up", + "moveDown": "Move down", + "remove": "Remove this node" + }, + "questView": { + "subtitle": "Quest", + "synopsisTitle": "Description", + "playerObjectivesTitle": "Player objectives", + "narrativeStakesTitle": "Narrative stakes", + "prerequisitesTitle": "Unlock conditions", + "nodesTitle": "Narrative nodes traversed", + "gmNotesTitle": "GM notes", + "relatedPagesTitle": "Linked Lore pages", + "notProvided": "Not provided", + "deleteTitle": "Delete quest", + "deleteMessage": "Delete the quest \"{{name}}\"?", + "irreversible": "This action is irreversible.", + "deletedPage": "(deleted page)", + "deletedQuest": "(deleted quest)", + "deletedNode": "(deleted node)", + "prereqQuestCompleted": "Quest \"{{name}}\" completed", + "prereqSessionReached": "Session {{n}} reached", + "prereqFlagSet": "Fact: {{flag}}", + "mapButton": "Scene map" + }, + "sceneType": { + "GENERIC": "Generic scene", + "LOCATION": "Location", + "ENCOUNTER": "Encounter", + "NPC": "NPC situation", + "EVENT": "Event", + "REVELATION": "Revelation" + }, + "linkType": { + "EXIT": "Exit", + "CLUE": "Clue", + "LEAD": "Lead" + }, + "clocksManager": { + "empty": "No clocks yet. Create one to track a threat or a countdown that advances as the game unfolds.", + "namePlaceholder": "Clock name (e.g. \"The cult completes its ritual\")", + "segments": "Segments", + "segmentsN": "{{n}} segments", + "add": "Add", + "advance": "Advance one segment", + "regress": "Roll back one segment", + "triggerType": "Auto trigger", + "triggerOption": { + "NONE": "Manual", + "FLAG_SET": "When a Fact becomes true", + "QUEST_COMPLETED": "When a quest completes", + "SESSION_ENDED": "At session end" + }, + "flagPlaceholder": "Fact name", + "quest": "Quest", + "selectQuest": "— Choose a quest —", + "triggerFlag": "Fact: {{name}}", + "triggerQuest": "Quest: {{name}}", + "triggerSession": "Session end", + "deletedQuest": "(deleted quest)", + "ungrouped": "Loose clocks", + "deleteFront": "Delete this front (its clocks become loose)", + "frontPlaceholder": "New front (threat: \"The rise of the Cult\"…)", + "addFront": "Add a front", + "front": "Front", + "noFront": "— No front —", + "edit": "Edit" + }, + "readiness": { + "title": "Next steps", + "badge": { + "DRAFT": "Draft", + "PLAYABLE": "Playable", + "POLISHED": "Polished" + }, + "blockingN": "{{n}} blocking", + "recommendedN": "{{n}} recommended", + "allSet": "All set: this campaign is ready to play.", + "fix": "Fix", + "dot": { + "blocking": "To fix: a blocking gap, here or in an item below. See “Next steps” on the campaign.", + "recommended": "Suggested: a recommended gap, here or below. See “Next steps” on the campaign.", + "more": "+ {{n}} more gap(s) — see “Next steps”" + } + }, + "sceneAssist": { + "button": "Flesh out with AI", + "hint": "The co-GM suggests values for the scene's fields. Nothing is saved: pick what fills the form, then save as usual.", + "instructionPlaceholder": "Optional instruction (e.g. \"make the mood more ominous\", \"add a clue about the cult\")…", + "generate": "Generate suggestions", + "generating": "Generating…", + "regenerate": "Regenerate", + "applySelection": "Apply selection ({{n}})", + "empty": "The AI had nothing new to suggest — the scene may already be complete, or rephrase your instruction.", + "error": "Generation failed. Is the Brain (AI) running?", + "current": "Current", + "proposed": "Proposed", + "field": { + "description": "Description", + "location": "Location", + "timing": "Timing", + "atmosphere": "Atmosphere", + "playerNarration": "Player narration", + "choicesConsequences": "Choices & consequences", + "combatDifficulty": "Combat difficulty", + "enemies": "Enemies", + "gmSecretNotes": "GM secret notes" + } + }, + "entityAssist": { + "button": "Flesh out with AI", + "hint": "The co-GM suggests values for the fields. Nothing is saved: pick what fills the form, then save as usual.", + "instructionPlaceholder": "Optional instruction (e.g. \"make the mood more ominous\", \"add a clue about the cult\")…", + "generate": "Generate suggestions", + "generating": "Generating…", + "regenerate": "Regenerate", + "applySelection": "Apply selection ({{n}})", + "empty": "The AI had nothing new to suggest — the entity may already be complete, or rephrase your instruction.", + "error": "Generation failed. Is the Brain (AI) running?", + "current": "Current", + "proposed": "Proposed", + "field": { + "description": "Description", + "location": "Location", + "timing": "Timing", + "atmosphere": "Atmosphere", + "playerNarration": "Player narration", + "choicesConsequences": "Choices & consequences", + "combatDifficulty": "Combat difficulty", + "enemies": "Enemies", + "gmSecretNotes": "GM secret notes", + "gmNotes": "GM notes", + "playerObjectives": "Player objectives", + "narrativeStakes": "Narrative stakes", + "themes": "Themes", + "stakes": "Stakes", + "rewards": "Rewards", + "resolution": "Resolution" + } + }, + "sceneDraft": { + "button": "Generate scenes", + "hint": "The co-GM suggests several scenes for this chapter. Tick the ones to keep; they are created in the chapter (you can refine them afterwards).", + "instructionPlaceholder": "Optional instruction (e.g. \"an infiltration, a fight, then a reveal\")…", + "countLabel": "How many", + "generate": "Suggest scenes", + "generating": "Generating…", + "regenerate": "Regenerate", + "createSelection": "Create selection ({{n}})", + "creating": "Creating…", + "empty": "The AI suggested no scenes — try rephrasing your instruction.", + "error": "Generation failed. Is the Brain (AI) running?", + "applyError": "Failed to create the scenes." + }, + "sessionPrep": { + "lastSessionActive": "Session in progress: {{name}}", + "lastSessionEnded": "Last session: {{name}} (ended)", + "allQuiet": "All clear: no active quest and no gap in upcoming content. Start the next session whenever you like.", + "positionTitle": "Where the players are", + "inProgress": "In progress:", + "available": "Available:", + "hotspotsTitle": "Likely content for the next session", + "nodeKind": { + "CHAPTER": "chapter", + "SCENE": "scene" + }, + "gapsTitle": "To fill in before playing", + "fix": "Fix", + "otherGaps": "+ {{n}} other gap(s) elsewhere in the campaign", + "clocksTitle": "Threats in motion", + "clockNearly": "nearly full!", + "clockFull": "full!", + "completed": "Completed:", + "markStarted": "Mark as in progress (the players started it)", + "markCompleted": "Mark as completed (removed from likely leads)", + "reopen": "Reopen (back to not started)" + }, + "foundryDialog": { + "title": "Export to Foundry", + "intro": "Choose what the Foundry module should import. For example, to run your combats, export only the maps and the enemies prepared in your scenes.", + "presetAll": "Everything", + "presetMapsOnly": "Maps & enemies only", + "optMaps": "Maps & enemies", + "optMapsHint": "One Foundry Scene per battlemap (day/night variants included), with the tokens of the enemies linked to each scene.", + "optJournals": "Narrative journals", + "optJournalsHint": "Arcs, chapters, quests and scenes as journals (texts + illustrations), plus NPC and bestiary sheets.", + "optTables": "Random tables", + "optTablesHint": "Your encounter and event tables as Foundry RollTables.", + "confirm": "Export" } } diff --git a/web/src/assets/i18n/fr.json b/web/src/assets/i18n/fr.json index 3c9ecaa..d0f4ead 100644 --- a/web/src/assets/i18n/fr.json +++ b/web/src/assets/i18n/fr.json @@ -378,7 +378,9 @@ "removeLink": "Retirer le lien", "increment": "Un de plus", "decrement": "Un de moins", - "noMatch": "Aucun ennemi ne correspond" + "noMatch": "Aucun ennemi ne correspond", + "broken": "Référence introuvable", + "brokenTitle": "Cette scène référence une fiche du bestiaire supprimée (ennemi supprimé puis recréé ?). Cliquez sur × pour retirer la référence morte." }, "personaView": { "empty": "Cette fiche est encore vide." @@ -508,7 +510,11 @@ "chatSuggestion3": "Suggère un dénouement en deux actes", "deleteTitle": "Supprimer l'arc", "deleteMessage": "Supprimer l'arc \"{{name}}\" ?", - "irreversible": "Cette action est irréversible." + "irreversible": "Cette action est irréversible.", + "optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.", + "themesStakesSectionTitle": "Thèmes et enjeux", + "gmNotesSectionTitle": "Notes du MJ", + "rewardsResolutionSectionTitle": "Récompenses et dénouement" }, "arcView": { "subtitleHub": "Arc en hub (quêtes non linéaires)", @@ -537,7 +543,10 @@ "deleteCascade": "Cette action supprimera aussi : {{parts}}.", "irreversible": "Cette action est irréversible.", "deleteConfirmTitle": "Supprimer l'arc", - "deleteConfirmMessage": "Supprimer l'arc \"{{name}}\" ?" + "deleteConfirmMessage": "Supprimer l'arc \"{{name}}\" ?", + "questsTitle": "Quêtes de cet arc", + "questsEmpty": "Aucune quête pour l'instant. Ajoutez-en une pour peupler ce hub.", + "createQuest": "Nouvelle quête" }, "campaigns": { "heroTitle": "Vos Campagnes", @@ -625,7 +634,13 @@ "playthroughsPlural": "{{n}} parties (avec ses PJ, sessions, faits)", "alsoDeletes": "Sera aussi supprimé : {{items}}.", "irreversible": "Cette action est irréversible." - } + }, + "quickScene": "Scène", + "quickSceneFirst": "Ajouter une scène", + "organizeInArcs": "ou organiser en arcs", + "defaultArcName": "Arc principal", + "defaultChapterName": "Chapitre 1", + "flatModeHint": "Mode simple : vos scènes sont dans le volet de gauche. Ajoutez-en une, ou organisez la campagne en arcs." }, "campaignImport": { "pageTitle": "Importer une campagne", @@ -726,7 +741,11 @@ "chatSuggestion3": "Suggère une scène d'ouverture marquante", "deleteTitle": "Supprimer le chapitre", "deleteMessage": "Supprimer le chapitre \"{{name}}\" ?", - "irreversible": "Cette action est irréversible." + "irreversible": "Cette action est irréversible.", + "optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.", + "gmNotesSectionTitle": "Notes du MJ", + "objectivesStakesSectionTitle": "Objectifs et enjeux", + "quest": "Quête" }, "chapterGraph": { "chapter": "Chapitre", @@ -734,8 +753,31 @@ "subtitle": "Organigramme des scènes et de leurs branches narratives", "back": "Retour au chapitre", "empty": "Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.", - "hint": "💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.", - "allCampaigns": "Toutes les campagnes" + "hint": "💡 Cliquez une scène pour l'ouvrir, glissez-la pour la déplacer. Glissez la pastille sous un nœud vers un autre pour créer un lien ; cliquez un lien pour l'éditer ou le supprimer. Molette = zoom, glisser le fond = déplacer la vue.", + "allCampaigns": "Toutes les campagnes", + "addScene": "Ajouter une scène", + "newSceneName": "Nouvelle scène", + "titleFlat": "Carte des scènes", + "backFlat": "Retour à la campagne", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "fit": "Recadrer sur l'ensemble", + "linkEditor": { + "label": "Libellé du choix", + "labelPlaceholder": "ex : « Suivre la piste »", + "condition": "Condition (notes MJ)", + "conditionPlaceholder": "ex : « si les PJ ont la clé »", + "type": "Type de lien", + "delete": "Supprimer le lien", + "close": "Fermer", + "kind": { + "EXIT": "Sortie", + "CLUE": "Indice", + "LEAD": "Piste" + } + }, + "rename": "Renommer la scène", + "renamePlaceholder": "Nom de la scène" }, "chapterView": { "chapter": "Chapitre", @@ -941,8 +983,8 @@ "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cette scène", "illustrationsLabel": "Illustrations", "illustrationsHint": "Portraits des PNJ, ambiance visuelle, scenes evocatrices...", - "battlemapLabel": "Battlemap (export Foundry)", - "battlemapHint": "Carte de combat a exporter vers Foundry : media (image/video) + fichier .json/.dd2vtt Universal VTT (Dungeon Alchemist, Dungeondraft...). Non affichee dans l'appli.", + "battlemapLabel": "Battlemaps (export Foundry)", + "battlemapHint": "Cartes de combat a exporter vers Foundry : media (image/video) + fichier .json/.dd2vtt Universal VTT (Dungeon Alchemist, Dungeondraft...). Ajoutez plusieurs cartes pour les variantes d'une meme scene (jour/nuit, etages...). Non affichees dans l'appli.", "battlemapMedia": "Media (image / video)", "battlemapData": "Donnees (.json / .dd2vtt)", "battlemapChoose": "Choisir un fichier", @@ -951,7 +993,7 @@ "battlemapRemove": "Retirer", "nameLabel": "Titre de la scène *", "namePlaceholder": "Ex: Arrivée au village", - "descriptionLabel": "Description courte *", + "descriptionLabel": "Description courte", "descriptionPlaceholder": "Résumé en une ou deux phrases de ce qui se passe...", "iconLabel": "Icône", "contextSectionTitle": "Contexte et ambiance", @@ -1007,7 +1049,15 @@ "battlemapSourceDD": "DungeonDraft (.dd2vtt)", "battlemapDd2vttSlot": "Fichier .dd2vtt", "battlemapDd2vttHint": "Le .dd2vtt contient la carte ET les murs/lumières ; l'image embarquée est extraite automatiquement pour l'export Foundry.", - "battlemapDd2vttNoImage": "Aucune image trouvée dans ce .dd2vtt — la carte exportée n'aura pas de fond." + "battlemapDd2vttNoImage": "Aucune image trouvée dans ce .dd2vtt — la carte exportée n'aura pas de fond.", + "optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.", + "nodeTypeLabel": "Type de nœud", + "nodeTypeHint": "Niveau 2 : typer la scène (lieu, rencontre, PNJ…) colore le nœud dans la vue graphe. Optionnel.", + "branchKindLabel": "Type de lien", + "branchTargetBroken": "Destination introuvable (scène supprimée) : choisissez une nouvelle destination ou retirez cette branche.", + "battlemapAdd": "Ajouter une carte", + "battlemapLabelPlaceholder": "Libellé (ex : Jour, Nuit, Étage 1…)", + "battlemapRemoveMap": "Retirer cette carte" }, "sceneView": { "subtitle": "Scène", @@ -1411,6 +1461,20 @@ "deleteEntryConfirm": { "title": "Supprimer cette entrée ?", "message": "Cette entrée du journal sera définitivement supprimée." + }, + "pinned": { + "label": "Scène courante", + "open": "Ouvrir la scène (nouvel onglet)", + "unpin": "Retirer l'épingle", + "showNarration": "Afficher la narration joueurs", + "hideNarration": "Masquer la narration" + }, + "recap": { + "button": "Récap « précédemment… »", + "loading": "Rédaction du récap…", + "title": "Précédemment ({{name}})", + "addToJournal": "Consigner au journal", + "error": "Échec du récap. Le Brain (IA) est-il démarré ?" } }, "sessionReferencePanel": { @@ -1423,7 +1487,12 @@ "playerCharacters": "Personnages joueurs", "nonPlayerCharacters": "Personnages non-joueurs", "emptyCharacters": "Aucun personnage dans cette campagne.", - "emptyScenes": "Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici." + "emptyScenes": "Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici.", + "tabPlay": "Partie", + "playClocks": "Horloges", + "playFacts": "Faits", + "playNoPlaythrough": "Cette session n'est rattachée à aucune Partie.", + "pinScene": "Épingler comme scène courante" }, "sessionDicePanel": { "count": "Nombre", @@ -1521,7 +1590,9 @@ "deleteCascade": "Cette action supprimera aussi : {{parts}}.", "irreversible": "Cette action est irréversible.", "deleteTitle": "Supprimer la Partie", - "deleteMessage": "Supprimer \"{{name}}\" ?" + "deleteMessage": "Supprimer \"{{name}}\" ?", + "clocksTitle": "Horloges de progression", + "prepTitle": "Préparer la prochaine séance" }, "playthroughFlagsPage": { "title": "Faits — {{name}}", @@ -1546,7 +1617,9 @@ "allCampaigns": "Toutes les campagnes", "sectionCharacters": "Personnages", "sectionNarration": "Narration", - "sectionTools": "Outils" + "sectionTools": "Outils", + "scenesGroup": "Scènes", + "quests": "Quêtes" }, "services": { "importFailed": "Échec de l'import.", @@ -1563,5 +1636,244 @@ "zoomOut": "Dézoomer", "dragHint": "Glisser pour recadrer", "remove": "Retirer cette image" + }, + "questList": { + "title": "Quêtes", + "create": "Nouvelle quête", + "hint": "Les quêtes sont transversales à la structure : elles portent des objectifs et des conditions de déblocage, et peuvent traverser plusieurs scènes.", + "empty": "Aucune quête pour l'instant.", + "conditional": "Conditions de déblocage", + "deleteTitle": "Supprimer la quête", + "deleteMessage": "Supprimer la quête « {{name}} » ?", + "irreversible": "Cette action est irréversible." + }, + "questEdit": { + "newTitle": "Nouvelle quête", + "fallbackTitle": "Quête", + "subtitle": "Quête", + "optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.", + "nameLabel": "Titre de la quête *", + "namePlaceholder": "Ex: Sauver le marchand disparu", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Décrivez l'objectif et le contexte de la quête...", + "iconLabel": "Icône", + "unlockSectionTitle": "Conditions de déblocage", + "unlockHint": "Toutes les conditions doivent être remplies (ET) pour que la quête se débloque dans une Partie. Vide = disponible dès le départ. La progression se gère dans l'écran de la Partie.", + "gmNotesSectionTitle": "Notes du MJ", + "gmNotesPlaceholder": "Notes privées sur la quête, les rebondissements, les secrets...", + "objectivesSectionTitle": "Objectifs et enjeux", + "playerObjectivesLabel": "Objectifs des joueurs", + "playerObjectivesPlaceholder": "Que doivent accomplir les joueurs ?", + "narrativeStakesLabel": "Enjeux narratifs", + "narrativeStakesPlaceholder": "Quels sont les enjeux dramatiques ?", + "deleteTitle": "Supprimer la quête", + "deleteMessage": "Supprimer la quête « {{name}} » ?", + "irreversible": "Cette action est irréversible.", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Portraits, ambiances et lieux marquants de la quête.", + "nodesSectionTitle": "Nœuds narratifs traversés", + "nodesHint": "FACULTATIF — ne servez-vous en que si cette quête doit TRAVERSER du contenu existant (« la quête passe par ces endroits »). Ce sont des liens : retirer un nœud ne supprime rien. Sans nœud, la quête reçoit automatiquement son propre espace de scènes (créées à la volée, avec sa carte).", + "relatedPagesLabel": "Pages Lore liées", + "relatedPagesHint": "Liez cette quête à des PNJ, des lieux ou des éléments du Lore qui y apparaissent.", + "noLoreHint": "💡 Cette campagne n'est pas liée à un univers. Liez-la à un Lore depuis l'écran de campagne pour pouvoir rattacher des pages Lore à cette quête." + }, + "nodePicker": { + "chapter": "Chapitre", + "scene": "Scène", + "selectChapter": "Ajouter un chapitre...", + "selectScene": "Ajouter une scène...", + "addChapter": "Ajouter", + "addScene": "Ajouter", + "empty": "Aucun nœud rattaché. Cette quête n'est encore liée à aucun chapitre ni scène.", + "deletedNode": "(nœud supprimé)", + "moveUp": "Monter", + "moveDown": "Descendre", + "remove": "Retirer ce nœud" + }, + "questView": { + "subtitle": "Quête", + "synopsisTitle": "Description", + "playerObjectivesTitle": "Objectifs des joueurs", + "narrativeStakesTitle": "Enjeux narratifs", + "prerequisitesTitle": "Conditions de déblocage", + "nodesTitle": "Nœuds narratifs traversés", + "gmNotesTitle": "Notes du MJ", + "relatedPagesTitle": "Pages Lore liées", + "notProvided": "Non renseigné", + "deleteTitle": "Supprimer la quête", + "deleteMessage": "Supprimer la quête « {{name}} » ?", + "irreversible": "Cette action est irréversible.", + "deletedPage": "(page supprimée)", + "deletedQuest": "(quête supprimée)", + "deletedNode": "(nœud supprimé)", + "prereqQuestCompleted": "Quête « {{name}} » terminée", + "prereqSessionReached": "Séance {{n}} atteinte", + "prereqFlagSet": "Fait : {{flag}}", + "mapButton": "Carte des scènes" + }, + "sceneType": { + "GENERIC": "Scène générique", + "LOCATION": "Lieu", + "ENCOUNTER": "Rencontre", + "NPC": "Situation PNJ", + "EVENT": "Événement", + "REVELATION": "Révélation" + }, + "linkType": { + "EXIT": "Sortie", + "CLUE": "Indice", + "LEAD": "Piste" + }, + "clocksManager": { + "empty": "Aucune horloge. Créez-en une pour suivre une menace ou un compte à rebours qui avance avec la partie.", + "namePlaceholder": "Nom de l'horloge (ex : « Le culte achève son rituel »)", + "segments": "Segments", + "segmentsN": "{{n}} segments", + "add": "Ajouter", + "advance": "Avancer d'un segment", + "regress": "Reculer d'un segment", + "triggerType": "Déclencheur auto", + "triggerOption": { + "NONE": "Manuel", + "FLAG_SET": "Quand un Fait devient vrai", + "QUEST_COMPLETED": "Quand une quête se termine", + "SESSION_ENDED": "En fin de séance" + }, + "flagPlaceholder": "Nom du fait", + "quest": "Quête", + "selectQuest": "— Choisir une quête —", + "triggerFlag": "Fait : {{name}}", + "triggerQuest": "Quête : {{name}}", + "triggerSession": "Fin de séance", + "deletedQuest": "(quête supprimée)", + "ungrouped": "Horloges libres", + "deleteFront": "Supprimer cette menace (les horloges deviennent libres)", + "frontPlaceholder": "Nouvelle menace (ex : « La montée du Culte »…)", + "addFront": "Ajouter une menace", + "front": "Menace", + "noFront": "— Sans menace —", + "edit": "Modifier" + }, + "readiness": { + "title": "Prochaines étapes", + "badge": { + "DRAFT": "Brouillon", + "PLAYABLE": "Jouable", + "POLISHED": "Peaufiné" + }, + "blockingN": "{{n}} bloquant(s)", + "recommendedN": "{{n}} recommandé(s)", + "allSet": "Tout est prêt : cette campagne peut être jouée sans accroc.", + "fix": "Corriger", + "dot": { + "blocking": "À corriger : un manque bloquant, ici ou dans un élément en dessous. Voir « Prochaines étapes » sur la campagne.", + "recommended": "Conseillé : un manque recommandé, ici ou en dessous. Voir « Prochaines étapes » sur la campagne.", + "more": "+ {{n}} autre(s) manque(s) — voir « Prochaines étapes »" + } + }, + "sceneAssist": { + "button": "Étoffer avec l'IA", + "hint": "Le co-MJ propose des valeurs pour les champs de la scène. Rien n'est enregistré : vous choisissez ce qui remplit le formulaire, puis vous sauvegardez.", + "instructionPlaceholder": "Consigne facultative (ex : « ambiance plus inquiétante », « ajoute un indice sur le culte »)…", + "generate": "Générer des suggestions", + "generating": "Génération…", + "regenerate": "Régénérer", + "applySelection": "Appliquer la sélection ({{n}})", + "empty": "L'IA n'a rien proposé de neuf — la scène est peut-être déjà complète, ou reformulez la consigne.", + "error": "Échec de la génération. Le Brain (IA) est-il démarré ?", + "current": "Actuel", + "proposed": "Proposé", + "field": { + "description": "Description", + "location": "Lieu", + "timing": "Moment", + "atmosphere": "Ambiance", + "playerNarration": "Narration joueurs", + "choicesConsequences": "Choix & conséquences", + "combatDifficulty": "Difficulté de combat", + "enemies": "Ennemis", + "gmSecretNotes": "Notes secrètes du MJ" + } + }, + "entityAssist": { + "button": "Étoffer avec l'IA", + "hint": "Le co-MJ propose des valeurs pour les champs. Rien n'est enregistré : vous choisissez ce qui remplit le formulaire, puis vous sauvegardez.", + "instructionPlaceholder": "Consigne facultative (ex : « ambiance plus inquiétante », « ajoute un indice sur le culte »)…", + "generate": "Générer des suggestions", + "generating": "Génération…", + "regenerate": "Régénérer", + "applySelection": "Appliquer la sélection ({{n}})", + "empty": "L'IA n'a rien proposé de neuf — l'entité est peut-être déjà complète, ou reformulez la consigne.", + "error": "Échec de la génération. Le Brain (IA) est-il démarré ?", + "current": "Actuel", + "proposed": "Proposé", + "field": { + "description": "Description", + "location": "Lieu", + "timing": "Moment", + "atmosphere": "Ambiance", + "playerNarration": "Narration joueurs", + "choicesConsequences": "Choix & conséquences", + "combatDifficulty": "Difficulté de combat", + "enemies": "Ennemis", + "gmSecretNotes": "Notes secrètes du MJ", + "gmNotes": "Notes du MJ", + "playerObjectives": "Objectifs des joueurs", + "narrativeStakes": "Enjeux narratifs", + "themes": "Thèmes", + "stakes": "Enjeux", + "rewards": "Récompenses", + "resolution": "Dénouement" + } + }, + "sceneDraft": { + "button": "Générer des scènes", + "hint": "Le co-MJ propose plusieurs scènes pour ce chapitre. Cochez celles à garder ; elles sont créées dans le chapitre (vous pourrez les affiner ensuite).", + "instructionPlaceholder": "Consigne facultative (ex : « une infiltration, un combat, puis une révélation »)…", + "countLabel": "Nombre", + "generate": "Proposer des scènes", + "generating": "Génération…", + "regenerate": "Régénérer", + "createSelection": "Créer la sélection ({{n}})", + "creating": "Création…", + "empty": "L'IA n'a proposé aucune scène — reformulez la consigne.", + "error": "Échec de la génération. Le Brain (IA) est-il démarré ?", + "applyError": "Échec de la création des scènes." + }, + "sessionPrep": { + "lastSessionActive": "Séance en cours : {{name}}", + "lastSessionEnded": "Dernière séance : {{name}} (terminée)", + "allQuiet": "Rien à signaler : pas de quête active ni de manque sur le contenu à venir. Lancez la prochaine séance quand vous voulez.", + "positionTitle": "Où en sont les joueurs", + "inProgress": "En cours :", + "available": "Disponibles :", + "hotspotsTitle": "Contenu probable de la prochaine séance", + "nodeKind": { + "CHAPTER": "chapitre", + "SCENE": "scène" + }, + "gapsTitle": "À combler avant de jouer", + "fix": "Corriger", + "otherGaps": "+ {{n}} autre(s) manque(s) ailleurs dans la campagne", + "clocksTitle": "Menaces en mouvement", + "clockNearly": "presque pleine !", + "clockFull": "pleine !", + "completed": "Terminées :", + "markStarted": "Marquer « en cours » (les joueurs l'ont entamée)", + "markCompleted": "Marquer « terminée » (elle sort des pistes probables)", + "reopen": "Rouvrir (revenir à « non commencée »)" + }, + "foundryDialog": { + "title": "Exporter pour Foundry", + "intro": "Choisissez ce que le module Foundry doit importer. Par exemple, pour jouer vos combats, exportez seulement les cartes et les ennemis préparés dans vos scènes.", + "presetAll": "Tout", + "presetMapsOnly": "Cartes & ennemis seulement", + "optMaps": "Cartes & ennemis", + "optMapsHint": "Une Scène Foundry par battlemap (variantes Jour/Nuit incluses), avec les tokens des ennemis liés à chaque scène.", + "optJournals": "Journaux narratifs", + "optJournalsHint": "Arcs, chapitres, quêtes et scènes en journaux (textes + illustrations), plus les fiches PNJ et bestiaire.", + "optTables": "Tables aléatoires", + "optTablesHint": "Vos tables de rencontres et d'événements en RollTables Foundry.", + "confirm": "Exporter" } } diff --git a/web/src/assets/i18n/fragments/arcs.en.json b/web/src/assets/i18n/fragments/arcs.en.json index 712ef98..b6db065 100644 --- a/web/src/assets/i18n/fragments/arcs.en.json +++ b/web/src/assets/i18n/fragments/arcs.en.json @@ -17,6 +17,10 @@ "subtitle": "Story arc", "aiAssistant": "AI Assistant", "aiAssistantTitle": "Open the AI Assistant to discuss this arc", + "optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.", + "themesStakesSectionTitle": "Themes and stakes", + "gmNotesSectionTitle": "GM notes", + "rewardsResolutionSectionTitle": "Rewards and resolution", "illustrationsLabel": "Illustrations", "illustrationsHint": "Moods, portraits, evocative visuals for the arc. JPEG, PNG, WebP or GIF, 10 MB max.", "mapsLabel": "Maps & plans", @@ -56,6 +60,9 @@ "arcView": { "subtitleHub": "Hub arc (non-linear quests)", "subtitleLinear": "Story arc", + "questsTitle": "Quests in this arc", + "questsEmpty": "No quests yet. Add one to populate this hub.", + "createQuest": "New quest", "deleteTitle": "Delete the arc and all its content", "mapsTitle": "Maps & plans", "hubQuestsTitle": "Hub quests (scenario)", diff --git a/web/src/assets/i18n/fragments/arcs.fr.json b/web/src/assets/i18n/fragments/arcs.fr.json index 5a15b59..dbdcf7b 100644 --- a/web/src/assets/i18n/fragments/arcs.fr.json +++ b/web/src/assets/i18n/fragments/arcs.fr.json @@ -17,6 +17,10 @@ "subtitle": "Arc narratif", "aiAssistant": "Assistant IA", "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cet arc", + "optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.", + "themesStakesSectionTitle": "Thèmes et enjeux", + "gmNotesSectionTitle": "Notes du MJ", + "rewardsResolutionSectionTitle": "Récompenses et dénouement", "illustrationsLabel": "Illustrations", "illustrationsHint": "Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max.", "mapsLabel": "Cartes & plans", @@ -56,6 +60,9 @@ "arcView": { "subtitleHub": "Arc en hub (quêtes non linéaires)", "subtitleLinear": "Arc narratif", + "questsTitle": "Quêtes de cet arc", + "questsEmpty": "Aucune quête pour l'instant. Ajoutez-en une pour peupler ce hub.", + "createQuest": "Nouvelle quête", "deleteTitle": "Supprimer l'arc et tout son contenu", "mapsTitle": "Cartes & plans", "hubQuestsTitle": "Quêtes du hub (scénario)", diff --git a/web/src/assets/i18n/fragments/campaign-tree.en.json b/web/src/assets/i18n/fragments/campaign-tree.en.json index fe670ed..4433c98 100644 --- a/web/src/assets/i18n/fragments/campaign-tree.en.json +++ b/web/src/assets/i18n/fragments/campaign-tree.en.json @@ -4,6 +4,8 @@ "unclassified": "Unfiled", "newNpc": "New NPC", "newScene": "New scene", + "scenesGroup": "Scenes", + "quests": "Quests", "newQuest": "New quest", "newChapter": "New chapter", "randomTables": "Random tables", diff --git a/web/src/assets/i18n/fragments/campaign-tree.fr.json b/web/src/assets/i18n/fragments/campaign-tree.fr.json index 8d64757..1adde94 100644 --- a/web/src/assets/i18n/fragments/campaign-tree.fr.json +++ b/web/src/assets/i18n/fragments/campaign-tree.fr.json @@ -4,6 +4,8 @@ "unclassified": "Sans dossier", "newNpc": "Nouveau PNJ", "newScene": "Nouvelle scène", + "scenesGroup": "Scènes", + "quests": "Quêtes", "newQuest": "Nouvelle quête", "newChapter": "Nouveau chapitre", "randomTables": "Tables aléatoires", diff --git a/web/src/assets/i18n/fragments/campaigns-top.en.json b/web/src/assets/i18n/fragments/campaigns-top.en.json index 2c4bc52..876963f 100644 --- a/web/src/assets/i18n/fragments/campaigns-top.en.json +++ b/web/src/assets/i18n/fragments/campaigns-top.en.json @@ -10,6 +10,19 @@ "newCampaignSubtitle": "Create a new adventure", "tip": "💡 Tip: Organize your arcs and chapters so you never forget anything in your adventures" }, + "foundryDialog": { + "title": "Export to Foundry", + "intro": "Choose what the Foundry module should import. For example, to run your combats, export only the maps and the enemies prepared in your scenes.", + "presetAll": "Everything", + "presetMapsOnly": "Maps & enemies only", + "optMaps": "Maps & enemies", + "optMapsHint": "One Foundry Scene per battlemap (day/night variants included), with the tokens of the enemies linked to each scene.", + "optJournals": "Narrative journals", + "optJournalsHint": "Arcs, chapters, quests and scenes as journals (texts + illustrations), plus NPC and bestiary sheets.", + "optTables": "Random tables", + "optTablesHint": "Your encounter and event tables as Foundry RollTables.", + "confirm": "Export" + }, "campaignCreate": { "title": "Create a new Campaign", "nameLabel": "Campaign name *", @@ -53,7 +66,13 @@ "newArc": "New arc", "chapters": "{{n}} chapters", "noArc": "No narrative arcs yet.", + "quickScene": "Scene", + "quickSceneFirst": "Add a scene", + "organizeInArcs": "or organize into arcs", + "flatModeHint": "Simple mode: your scenes are in the left panel. Add one, or organize the campaign into arcs.", "createFirstArc": "Create your first arc", + "defaultArcName": "Main arc", + "defaultChapterName": "Chapter 1", "playthroughsTitle": "My playthroughs", "newPlaythrough": "New playthrough", "noPlaythrough": "No playthroughs. Create one to start a session with a table.", diff --git a/web/src/assets/i18n/fragments/campaigns-top.fr.json b/web/src/assets/i18n/fragments/campaigns-top.fr.json index ad91717..2e80ddc 100644 --- a/web/src/assets/i18n/fragments/campaigns-top.fr.json +++ b/web/src/assets/i18n/fragments/campaigns-top.fr.json @@ -10,6 +10,19 @@ "newCampaignSubtitle": "Créez une nouvelle aventure", "tip": "💡 Astuce : Organisez vos arcs et chapitres pour ne rien oublier de vos aventures" }, + "foundryDialog": { + "title": "Exporter pour Foundry", + "intro": "Choisissez ce que le module Foundry doit importer. Par exemple, pour jouer vos combats, exportez seulement les cartes et les ennemis préparés dans vos scènes.", + "presetAll": "Tout", + "presetMapsOnly": "Cartes & ennemis seulement", + "optMaps": "Cartes & ennemis", + "optMapsHint": "Une Scène Foundry par battlemap (variantes Jour/Nuit incluses), avec les tokens des ennemis liés à chaque scène.", + "optJournals": "Journaux narratifs", + "optJournalsHint": "Arcs, chapitres, quêtes et scènes en journaux (textes + illustrations), plus les fiches PNJ et bestiaire.", + "optTables": "Tables aléatoires", + "optTablesHint": "Vos tables de rencontres et d'événements en RollTables Foundry.", + "confirm": "Exporter" + }, "campaignCreate": { "title": "Créer une nouvelle Campagne", "nameLabel": "Nom de la campagne *", @@ -53,7 +66,13 @@ "newArc": "Nouvel arc", "chapters": "{{n}} chapitres", "noArc": "Aucun arc narratif pour le moment.", + "quickScene": "Scène", + "quickSceneFirst": "Ajouter une scène", + "organizeInArcs": "ou organiser en arcs", + "flatModeHint": "Mode simple : vos scènes sont dans le volet de gauche. Ajoutez-en une, ou organisez la campagne en arcs.", "createFirstArc": "Créer votre premier arc", + "defaultArcName": "Arc principal", + "defaultChapterName": "Chapitre 1", "playthroughsTitle": "Mes parties", "newPlaythrough": "Nouvelle partie", "noPlaythrough": "Aucune partie. Créez-en une pour démarrer une session avec une table.", diff --git a/web/src/assets/i18n/fragments/chapters.en.json b/web/src/assets/i18n/fragments/chapters.en.json index fb98f3d..ef8bf9e 100644 --- a/web/src/assets/i18n/fragments/chapters.en.json +++ b/web/src/assets/i18n/fragments/chapters.en.json @@ -16,8 +16,12 @@ }, "chapterEdit": { "chapter": "Chapter", + "quest": "Quest", "aiAssistant": "AI Assistant", "aiAssistantTitle": "Open the AI Assistant to discuss this chapter", + "optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.", + "gmNotesSectionTitle": "GM notes", + "objectivesStakesSectionTitle": "Objectives and stakes", "unlockTitleQuest": "🗺️ Quest unlock conditions", "unlockTitleChapter": "🔒 Chapter unlock conditions", "unlockHintQuest": "Scenario definition: all conditions must be met (AND) for the quest to unlock in a Playthrough. Leave empty to make it available from the start. Progression and fact states are managed in the Playthrough screen, not here.", @@ -52,11 +56,34 @@ "chapterGraph": { "chapter": "Chapter", "title": "{{name}} — Map", + "titleFlat": "Scene map", "subtitle": "Flowchart of scenes and their narrative branches", "back": "Back to chapter", + "backFlat": "Back to campaign", "empty": "This chapter has no scenes. Create some to see the map appear.", - "hint": "💡 Click a scene to open it, or drag it to rearrange the map. Scenes not connected to the entry point (scene of order 1) appear at the bottom.", - "allCampaigns": "All campaigns" + "hint": "💡 Click a scene to open it, drag it to move it. Drag the dot under a node onto another to create a link; click a link to edit or delete it. Wheel = zoom, drag the background = pan the view.", + "addScene": "Add a scene", + "newSceneName": "New scene", + "allCampaigns": "All campaigns", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "fit": "Fit to content", + "rename": "Rename scene", + "renamePlaceholder": "Scene name", + "linkEditor": { + "label": "Choice label", + "labelPlaceholder": "e.g. \"Follow the trail\"", + "condition": "Condition (GM notes)", + "conditionPlaceholder": "e.g. \"if the PCs have the key\"", + "type": "Link type", + "delete": "Delete link", + "close": "Close", + "kind": { + "EXIT": "Exit", + "CLUE": "Clue", + "LEAD": "Lead" + } + } }, "chapterView": { "chapter": "Chapter", diff --git a/web/src/assets/i18n/fragments/chapters.fr.json b/web/src/assets/i18n/fragments/chapters.fr.json index 8d83fa1..6c86b0f 100644 --- a/web/src/assets/i18n/fragments/chapters.fr.json +++ b/web/src/assets/i18n/fragments/chapters.fr.json @@ -16,8 +16,12 @@ }, "chapterEdit": { "chapter": "Chapitre", + "quest": "Quête", "aiAssistant": "Assistant IA", "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre", + "optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.", + "gmNotesSectionTitle": "Notes du MJ", + "objectivesStakesSectionTitle": "Objectifs et enjeux", "unlockTitleQuest": "🗺️ Conditions de déblocage de la quête", "unlockTitleChapter": "🔒 Conditions de déblocage du chapitre", "unlockHintQuest": "Définition du scénario : toutes les conditions doivent être remplies (ET) pour que la quête soit débloquée dans une Partie. Laissez vide pour qu'elle soit disponible dès le départ. La progression et l'état des faits se gèrent dans l'écran de la Partie, pas ici.", @@ -52,11 +56,34 @@ "chapterGraph": { "chapter": "Chapitre", "title": "{{name}} — Carte", + "titleFlat": "Carte des scènes", "subtitle": "Organigramme des scènes et de leurs branches narratives", "back": "Retour au chapitre", + "backFlat": "Retour à la campagne", "empty": "Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.", - "hint": "💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.", - "allCampaigns": "Toutes les campagnes" + "hint": "💡 Cliquez une scène pour l'ouvrir, glissez-la pour la déplacer. Glissez la pastille sous un nœud vers un autre pour créer un lien ; cliquez un lien pour l'éditer ou le supprimer. Molette = zoom, glisser le fond = déplacer la vue.", + "addScene": "Ajouter une scène", + "newSceneName": "Nouvelle scène", + "allCampaigns": "Toutes les campagnes", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "fit": "Recadrer sur l'ensemble", + "rename": "Renommer la scène", + "renamePlaceholder": "Nom de la scène", + "linkEditor": { + "label": "Libellé du choix", + "labelPlaceholder": "ex : « Suivre la piste »", + "condition": "Condition (notes MJ)", + "conditionPlaceholder": "ex : « si les PJ ont la clé »", + "type": "Type de lien", + "delete": "Supprimer le lien", + "close": "Fermer", + "kind": { + "EXIT": "Sortie", + "CLUE": "Indice", + "LEAD": "Piste" + } + } }, "chapterView": { "chapter": "Chapitre", diff --git a/web/src/assets/i18n/fragments/clocks.en.json b/web/src/assets/i18n/fragments/clocks.en.json new file mode 100644 index 0000000..721469e --- /dev/null +++ b/web/src/assets/i18n/fragments/clocks.en.json @@ -0,0 +1,35 @@ +{ + "playthroughDetail": { + "clocksTitle": "Progress clocks" + }, + "clocksManager": { + "empty": "No clocks yet. Create one to track a threat or a countdown that advances as the game unfolds.", + "namePlaceholder": "Clock name (e.g. \"The cult completes its ritual\")", + "segments": "Segments", + "segmentsN": "{{n}} segments", + "add": "Add", + "advance": "Advance one segment", + "regress": "Roll back one segment", + "triggerType": "Auto trigger", + "triggerOption": { + "NONE": "Manual", + "FLAG_SET": "When a Fact becomes true", + "QUEST_COMPLETED": "When a quest completes", + "SESSION_ENDED": "At session end" + }, + "flagPlaceholder": "Fact name", + "quest": "Quest", + "selectQuest": "— Choose a quest —", + "triggerFlag": "Fact: {{name}}", + "triggerQuest": "Quest: {{name}}", + "triggerSession": "Session end", + "deletedQuest": "(deleted quest)", + "ungrouped": "Loose clocks", + "deleteFront": "Delete this front (its clocks become loose)", + "frontPlaceholder": "New front (threat: \"The rise of the Cult\"…)", + "addFront": "Add a front", + "front": "Front", + "noFront": "— No front —", + "edit": "Edit" + } +} diff --git a/web/src/assets/i18n/fragments/clocks.fr.json b/web/src/assets/i18n/fragments/clocks.fr.json new file mode 100644 index 0000000..36b6cfd --- /dev/null +++ b/web/src/assets/i18n/fragments/clocks.fr.json @@ -0,0 +1,35 @@ +{ + "playthroughDetail": { + "clocksTitle": "Horloges de progression" + }, + "clocksManager": { + "empty": "Aucune horloge. Créez-en une pour suivre une menace ou un compte à rebours qui avance avec la partie.", + "namePlaceholder": "Nom de l'horloge (ex : « Le culte achève son rituel »)", + "segments": "Segments", + "segmentsN": "{{n}} segments", + "add": "Ajouter", + "advance": "Avancer d'un segment", + "regress": "Reculer d'un segment", + "triggerType": "Déclencheur auto", + "triggerOption": { + "NONE": "Manuel", + "FLAG_SET": "Quand un Fait devient vrai", + "QUEST_COMPLETED": "Quand une quête se termine", + "SESSION_ENDED": "En fin de séance" + }, + "flagPlaceholder": "Nom du fait", + "quest": "Quête", + "selectQuest": "— Choisir une quête —", + "triggerFlag": "Fait : {{name}}", + "triggerQuest": "Quête : {{name}}", + "triggerSession": "Fin de séance", + "deletedQuest": "(quête supprimée)", + "ungrouped": "Horloges libres", + "deleteFront": "Supprimer cette menace (les horloges deviennent libres)", + "frontPlaceholder": "Nouvelle menace (ex : « La montée du Culte »…)", + "addFront": "Ajouter une menace", + "front": "Menace", + "noFront": "— Sans menace —", + "edit": "Modifier" + } +} diff --git a/web/src/assets/i18n/fragments/entity-assist.en.json b/web/src/assets/i18n/fragments/entity-assist.en.json new file mode 100644 index 0000000..91a489d --- /dev/null +++ b/web/src/assets/i18n/fragments/entity-assist.en.json @@ -0,0 +1,33 @@ +{ + "entityAssist": { + "button": "Flesh out with AI", + "hint": "The co-GM suggests values for the fields. Nothing is saved: pick what fills the form, then save as usual.", + "instructionPlaceholder": "Optional instruction (e.g. \"make the mood more ominous\", \"add a clue about the cult\")…", + "generate": "Generate suggestions", + "generating": "Generating…", + "regenerate": "Regenerate", + "applySelection": "Apply selection ({{n}})", + "empty": "The AI had nothing new to suggest — the entity may already be complete, or rephrase your instruction.", + "error": "Generation failed. Is the Brain (AI) running?", + "current": "Current", + "proposed": "Proposed", + "field": { + "description": "Description", + "location": "Location", + "timing": "Timing", + "atmosphere": "Atmosphere", + "playerNarration": "Player narration", + "choicesConsequences": "Choices & consequences", + "combatDifficulty": "Combat difficulty", + "enemies": "Enemies", + "gmSecretNotes": "GM secret notes", + "gmNotes": "GM notes", + "playerObjectives": "Player objectives", + "narrativeStakes": "Narrative stakes", + "themes": "Themes", + "stakes": "Stakes", + "rewards": "Rewards", + "resolution": "Resolution" + } + } +} diff --git a/web/src/assets/i18n/fragments/entity-assist.fr.json b/web/src/assets/i18n/fragments/entity-assist.fr.json new file mode 100644 index 0000000..3ac8f0a --- /dev/null +++ b/web/src/assets/i18n/fragments/entity-assist.fr.json @@ -0,0 +1,33 @@ +{ + "entityAssist": { + "button": "Étoffer avec l'IA", + "hint": "Le co-MJ propose des valeurs pour les champs. Rien n'est enregistré : vous choisissez ce qui remplit le formulaire, puis vous sauvegardez.", + "instructionPlaceholder": "Consigne facultative (ex : « ambiance plus inquiétante », « ajoute un indice sur le culte »)…", + "generate": "Générer des suggestions", + "generating": "Génération…", + "regenerate": "Régénérer", + "applySelection": "Appliquer la sélection ({{n}})", + "empty": "L'IA n'a rien proposé de neuf — l'entité est peut-être déjà complète, ou reformulez la consigne.", + "error": "Échec de la génération. Le Brain (IA) est-il démarré ?", + "current": "Actuel", + "proposed": "Proposé", + "field": { + "description": "Description", + "location": "Lieu", + "timing": "Moment", + "atmosphere": "Ambiance", + "playerNarration": "Narration joueurs", + "choicesConsequences": "Choix & conséquences", + "combatDifficulty": "Difficulté de combat", + "enemies": "Ennemis", + "gmSecretNotes": "Notes secrètes du MJ", + "gmNotes": "Notes du MJ", + "playerObjectives": "Objectifs des joueurs", + "narrativeStakes": "Enjeux narratifs", + "themes": "Thèmes", + "stakes": "Enjeux", + "rewards": "Récompenses", + "resolution": "Dénouement" + } + } +} diff --git a/web/src/assets/i18n/fragments/quests.en.json b/web/src/assets/i18n/fragments/quests.en.json new file mode 100644 index 0000000..e2f22be --- /dev/null +++ b/web/src/assets/i18n/fragments/quests.en.json @@ -0,0 +1,76 @@ +{ + "questList": { + "title": "Quests", + "create": "New quest", + "hint": "Quests are orthogonal to the structure: they carry objectives and unlock conditions, and can span several scenes.", + "empty": "No quests yet.", + "conditional": "Unlock conditions", + "deleteTitle": "Delete quest", + "deleteMessage": "Delete the quest \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "questEdit": { + "newTitle": "New quest", + "fallbackTitle": "Quest", + "subtitle": "Quest", + "optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.", + "nameLabel": "Quest title *", + "namePlaceholder": "E.g.: Save the missing merchant", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Describe the objective and context of the quest...", + "iconLabel": "Icon", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Portraits, moods and notable locations of the quest.", + "unlockSectionTitle": "Unlock conditions", + "unlockHint": "All conditions must be met (AND) for the quest to unlock in a Playthrough. Empty = available from the start. Progression is managed in the Playthrough screen.", + "nodesSectionTitle": "Narrative nodes traversed", + "nodesHint": "OPTIONAL — only use this if the quest should TRAVERSE existing content (“the quest passes through these places”). These are links: removing a node deletes nothing. Without nodes, the quest automatically gets its own scene space (created on the fly, with its map).", + "gmNotesSectionTitle": "GM notes", + "gmNotesPlaceholder": "Private notes about the quest, twists, secrets...", + "objectivesSectionTitle": "Objectives and stakes", + "playerObjectivesLabel": "Player objectives", + "playerObjectivesPlaceholder": "What must the players accomplish?", + "narrativeStakesLabel": "Narrative stakes", + "narrativeStakesPlaceholder": "What are the dramatic stakes?", + "relatedPagesLabel": "Linked Lore pages", + "relatedPagesHint": "Link this quest to NPCs, places or Lore elements that appear in it.", + "noLoreHint": "💡 This campaign is not linked to any universe. Link it to a Lore from the campaign screen to attach Lore pages to this quest.", + "deleteTitle": "Delete quest", + "deleteMessage": "Delete the quest \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "nodePicker": { + "chapter": "Chapter", + "scene": "Scene", + "selectChapter": "Add a chapter...", + "selectScene": "Add a scene...", + "addChapter": "Add", + "addScene": "Add", + "empty": "No node attached yet. This quest isn't linked to any chapter or scene.", + "deletedNode": "(deleted node)", + "moveUp": "Move up", + "moveDown": "Move down", + "remove": "Remove this node" + }, + "questView": { + "subtitle": "Quest", + "mapButton": "Scene map", + "synopsisTitle": "Description", + "playerObjectivesTitle": "Player objectives", + "narrativeStakesTitle": "Narrative stakes", + "prerequisitesTitle": "Unlock conditions", + "nodesTitle": "Narrative nodes traversed", + "gmNotesTitle": "GM notes", + "relatedPagesTitle": "Linked Lore pages", + "notProvided": "Not provided", + "deleteTitle": "Delete quest", + "deleteMessage": "Delete the quest \"{{name}}\"?", + "irreversible": "This action is irreversible.", + "deletedPage": "(deleted page)", + "deletedQuest": "(deleted quest)", + "deletedNode": "(deleted node)", + "prereqQuestCompleted": "Quest \"{{name}}\" completed", + "prereqSessionReached": "Session {{n}} reached", + "prereqFlagSet": "Fact: {{flag}}" + } +} diff --git a/web/src/assets/i18n/fragments/quests.fr.json b/web/src/assets/i18n/fragments/quests.fr.json new file mode 100644 index 0000000..210b483 --- /dev/null +++ b/web/src/assets/i18n/fragments/quests.fr.json @@ -0,0 +1,76 @@ +{ + "questList": { + "title": "Quêtes", + "create": "Nouvelle quête", + "hint": "Les quêtes sont transversales à la structure : elles portent des objectifs et des conditions de déblocage, et peuvent traverser plusieurs scènes.", + "empty": "Aucune quête pour l'instant.", + "conditional": "Conditions de déblocage", + "deleteTitle": "Supprimer la quête", + "deleteMessage": "Supprimer la quête « {{name}} » ?", + "irreversible": "Cette action est irréversible." + }, + "questEdit": { + "newTitle": "Nouvelle quête", + "fallbackTitle": "Quête", + "subtitle": "Quête", + "optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.", + "nameLabel": "Titre de la quête *", + "namePlaceholder": "Ex: Sauver le marchand disparu", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Décrivez l'objectif et le contexte de la quête...", + "iconLabel": "Icône", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Portraits, ambiances et lieux marquants de la quête.", + "unlockSectionTitle": "Conditions de déblocage", + "unlockHint": "Toutes les conditions doivent être remplies (ET) pour que la quête se débloque dans une Partie. Vide = disponible dès le départ. La progression se gère dans l'écran de la Partie.", + "nodesSectionTitle": "Nœuds narratifs traversés", + "nodesHint": "FACULTATIF — ne servez-vous en que si cette quête doit TRAVERSER du contenu existant (« la quête passe par ces endroits »). Ce sont des liens : retirer un nœud ne supprime rien. Sans nœud, la quête reçoit automatiquement son propre espace de scènes (créées à la volée, avec sa carte).", + "gmNotesSectionTitle": "Notes du MJ", + "gmNotesPlaceholder": "Notes privées sur la quête, les rebondissements, les secrets...", + "objectivesSectionTitle": "Objectifs et enjeux", + "playerObjectivesLabel": "Objectifs des joueurs", + "playerObjectivesPlaceholder": "Que doivent accomplir les joueurs ?", + "narrativeStakesLabel": "Enjeux narratifs", + "narrativeStakesPlaceholder": "Quels sont les enjeux dramatiques ?", + "relatedPagesLabel": "Pages Lore liées", + "relatedPagesHint": "Liez cette quête à des PNJ, des lieux ou des éléments du Lore qui y apparaissent.", + "noLoreHint": "💡 Cette campagne n'est pas liée à un univers. Liez-la à un Lore depuis l'écran de campagne pour pouvoir rattacher des pages Lore à cette quête.", + "deleteTitle": "Supprimer la quête", + "deleteMessage": "Supprimer la quête « {{name}} » ?", + "irreversible": "Cette action est irréversible." + }, + "nodePicker": { + "chapter": "Chapitre", + "scene": "Scène", + "selectChapter": "Ajouter un chapitre...", + "selectScene": "Ajouter une scène...", + "addChapter": "Ajouter", + "addScene": "Ajouter", + "empty": "Aucun nœud rattaché. Cette quête n'est encore liée à aucun chapitre ni scène.", + "deletedNode": "(nœud supprimé)", + "moveUp": "Monter", + "moveDown": "Descendre", + "remove": "Retirer ce nœud" + }, + "questView": { + "subtitle": "Quête", + "mapButton": "Carte des scènes", + "synopsisTitle": "Description", + "playerObjectivesTitle": "Objectifs des joueurs", + "narrativeStakesTitle": "Enjeux narratifs", + "prerequisitesTitle": "Conditions de déblocage", + "nodesTitle": "Nœuds narratifs traversés", + "gmNotesTitle": "Notes du MJ", + "relatedPagesTitle": "Pages Lore liées", + "notProvided": "Non renseigné", + "deleteTitle": "Supprimer la quête", + "deleteMessage": "Supprimer la quête « {{name}} » ?", + "irreversible": "Cette action est irréversible.", + "deletedPage": "(page supprimée)", + "deletedQuest": "(quête supprimée)", + "deletedNode": "(nœud supprimé)", + "prereqQuestCompleted": "Quête « {{name}} » terminée", + "prereqSessionReached": "Séance {{n}} atteinte", + "prereqFlagSet": "Fait : {{flag}}" + } +} diff --git a/web/src/assets/i18n/fragments/readiness.en.json b/web/src/assets/i18n/fragments/readiness.en.json new file mode 100644 index 0000000..2ecdc0a --- /dev/null +++ b/web/src/assets/i18n/fragments/readiness.en.json @@ -0,0 +1,19 @@ +{ + "readiness": { + "title": "Next steps", + "badge": { + "DRAFT": "Draft", + "PLAYABLE": "Playable", + "POLISHED": "Polished" + }, + "blockingN": "{{n}} blocking", + "recommendedN": "{{n}} recommended", + "allSet": "All set: this campaign is ready to play.", + "fix": "Fix", + "dot": { + "blocking": "To fix: a blocking gap, here or in an item below. See “Next steps” on the campaign.", + "recommended": "Suggested: a recommended gap, here or below. See “Next steps” on the campaign.", + "more": "+ {{n}} more gap(s) — see “Next steps”" + } + } +} diff --git a/web/src/assets/i18n/fragments/readiness.fr.json b/web/src/assets/i18n/fragments/readiness.fr.json new file mode 100644 index 0000000..ca78c85 --- /dev/null +++ b/web/src/assets/i18n/fragments/readiness.fr.json @@ -0,0 +1,19 @@ +{ + "readiness": { + "title": "Prochaines étapes", + "badge": { + "DRAFT": "Brouillon", + "PLAYABLE": "Jouable", + "POLISHED": "Peaufiné" + }, + "blockingN": "{{n}} bloquant(s)", + "recommendedN": "{{n}} recommandé(s)", + "allSet": "Tout est prêt : cette campagne peut être jouée sans accroc.", + "fix": "Corriger", + "dot": { + "blocking": "À corriger : un manque bloquant, ici ou dans un élément en dessous. Voir « Prochaines étapes » sur la campagne.", + "recommended": "Conseillé : un manque recommandé, ici ou en dessous. Voir « Prochaines étapes » sur la campagne.", + "more": "+ {{n}} autre(s) manque(s) — voir « Prochaines étapes »" + } + } +} diff --git a/web/src/assets/i18n/fragments/scene-draft.en.json b/web/src/assets/i18n/fragments/scene-draft.en.json new file mode 100644 index 0000000..e36f308 --- /dev/null +++ b/web/src/assets/i18n/fragments/scene-draft.en.json @@ -0,0 +1,16 @@ +{ + "sceneDraft": { + "button": "Generate scenes", + "hint": "The co-GM suggests several scenes for this chapter. Tick the ones to keep; they are created in the chapter (you can refine them afterwards).", + "instructionPlaceholder": "Optional instruction (e.g. \"an infiltration, a fight, then a reveal\")…", + "countLabel": "How many", + "generate": "Suggest scenes", + "generating": "Generating…", + "regenerate": "Regenerate", + "createSelection": "Create selection ({{n}})", + "creating": "Creating…", + "empty": "The AI suggested no scenes — try rephrasing your instruction.", + "error": "Generation failed. Is the Brain (AI) running?", + "applyError": "Failed to create the scenes." + } +} diff --git a/web/src/assets/i18n/fragments/scene-draft.fr.json b/web/src/assets/i18n/fragments/scene-draft.fr.json new file mode 100644 index 0000000..5b51e7e --- /dev/null +++ b/web/src/assets/i18n/fragments/scene-draft.fr.json @@ -0,0 +1,16 @@ +{ + "sceneDraft": { + "button": "Générer des scènes", + "hint": "Le co-MJ propose plusieurs scènes pour ce chapitre. Cochez celles à garder ; elles sont créées dans le chapitre (vous pourrez les affiner ensuite).", + "instructionPlaceholder": "Consigne facultative (ex : « une infiltration, un combat, puis une révélation »)…", + "countLabel": "Nombre", + "generate": "Proposer des scènes", + "generating": "Génération…", + "regenerate": "Régénérer", + "createSelection": "Créer la sélection ({{n}})", + "creating": "Création…", + "empty": "L'IA n'a proposé aucune scène — reformulez la consigne.", + "error": "Échec de la génération. Le Brain (IA) est-il démarré ?", + "applyError": "Échec de la création des scènes." + } +} diff --git a/web/src/assets/i18n/fragments/scenes.en.json b/web/src/assets/i18n/fragments/scenes.en.json index 9b197fa..d0df0e4 100644 --- a/web/src/assets/i18n/fragments/scenes.en.json +++ b/web/src/assets/i18n/fragments/scenes.en.json @@ -17,8 +17,11 @@ "aiAssistantTitle": "Open the AI Assistant to discuss this scene", "illustrationsLabel": "Illustrations", "illustrationsHint": "NPC portraits, visual mood, evocative scenes...", - "battlemapLabel": "Battlemap (Foundry export)", - "battlemapHint": "Battle map to export to Foundry: media (image/video) + Universal VTT .json/.dd2vtt file (Dungeon Alchemist, Dungeondraft...). Not displayed in the app.", + "battlemapLabel": "Battlemaps (Foundry export)", + "battlemapHint": "Battle maps to export to Foundry: media (image/video) + Universal VTT .json/.dd2vtt file (Dungeon Alchemist, Dungeondraft...). Add several maps for variants of the same scene (day/night, floors...). Not displayed in the app.", + "battlemapAdd": "Add a map", + "battlemapLabelPlaceholder": "Label (e.g.: Day, Night, Floor 1…)", + "battlemapRemoveMap": "Remove this map", "battlemapMedia": "Media (image / video)", "battlemapData": "Data (.json / .dd2vtt)", "battlemapChoose": "Choose a file", @@ -31,11 +34,14 @@ "battlemapDd2vttSlot": ".dd2vtt file", "battlemapDd2vttHint": "The .dd2vtt contains the map AND the walls/lights; the embedded image is extracted automatically for the Foundry export.", "battlemapDd2vttNoImage": "No image found in this .dd2vtt — the exported map will have no background.", + "optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.", "nameLabel": "Scene title *", "namePlaceholder": "E.g.: Arrival at the village", - "descriptionLabel": "Short description *", + "descriptionLabel": "Short description", "descriptionPlaceholder": "One or two sentences summarizing what happens...", "iconLabel": "Icon", + "nodeTypeLabel": "Node type", + "nodeTypeHint": "Level 2: typing the scene (location, encounter, NPC…) colors the node in the graph view. Optional.", "contextSectionTitle": "Context and mood", "locationLabel": "Location", "locationPlaceholder": "E.g.: The Golden Dragon Tavern", @@ -57,8 +63,10 @@ "branchLabelPlaceholder": "E.g.: If the players attack the guard", "branchTargetLabel": "Destination scene *", "branchTargetPlaceholder": "— Choose a scene —", + "branchTargetBroken": "Destination not found (deleted scene): pick a new destination or remove this branch.", "branchConditionLabel": "GM condition (optional)", "branchConditionPlaceholder": "E.g.: Persuasion check DC 15 succeeded", + "branchKindLabel": "Link type", "branchRemove": "Remove", "branchRemoveTitle": "Remove this branch", "branchAdd": "+ Add a branch", @@ -114,5 +122,18 @@ "deleteMessage": "Delete the scene \"{{name}}\"?", "deleteIrreversible": "This action is irreversible.", "deleteError": "Error while deleting the scene" + }, + "sceneType": { + "GENERIC": "Generic scene", + "LOCATION": "Location", + "ENCOUNTER": "Encounter", + "NPC": "NPC situation", + "EVENT": "Event", + "REVELATION": "Revelation" + }, + "linkType": { + "EXIT": "Exit", + "CLUE": "Clue", + "LEAD": "Lead" } } diff --git a/web/src/assets/i18n/fragments/scenes.fr.json b/web/src/assets/i18n/fragments/scenes.fr.json index d9e9496..a5bab6e 100644 --- a/web/src/assets/i18n/fragments/scenes.fr.json +++ b/web/src/assets/i18n/fragments/scenes.fr.json @@ -17,8 +17,11 @@ "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cette scène", "illustrationsLabel": "Illustrations", "illustrationsHint": "Portraits des PNJ, ambiance visuelle, scenes evocatrices...", - "battlemapLabel": "Battlemap (export Foundry)", - "battlemapHint": "Carte de combat a exporter vers Foundry : media (image/video) + fichier .json/.dd2vtt Universal VTT (Dungeon Alchemist, Dungeondraft...). Non affichee dans l'appli.", + "battlemapLabel": "Battlemaps (export Foundry)", + "battlemapHint": "Cartes de combat a exporter vers Foundry : media (image/video) + fichier .json/.dd2vtt Universal VTT (Dungeon Alchemist, Dungeondraft...). Ajoutez plusieurs cartes pour les variantes d'une meme scene (jour/nuit, etages...). Non affichees dans l'appli.", + "battlemapAdd": "Ajouter une carte", + "battlemapLabelPlaceholder": "Libellé (ex : Jour, Nuit, Étage 1…)", + "battlemapRemoveMap": "Retirer cette carte", "battlemapMedia": "Media (image / video)", "battlemapData": "Donnees (.json / .dd2vtt)", "battlemapChoose": "Choisir un fichier", @@ -31,11 +34,14 @@ "battlemapDd2vttSlot": "Fichier .dd2vtt", "battlemapDd2vttHint": "Le .dd2vtt contient la carte ET les murs/lumières ; l'image embarquée est extraite automatiquement pour l'export Foundry.", "battlemapDd2vttNoImage": "Aucune image trouvée dans ce .dd2vtt — la carte exportée n'aura pas de fond.", + "optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.", "nameLabel": "Titre de la scène *", "namePlaceholder": "Ex: Arrivée au village", - "descriptionLabel": "Description courte *", + "descriptionLabel": "Description courte", "descriptionPlaceholder": "Résumé en une ou deux phrases de ce qui se passe...", "iconLabel": "Icône", + "nodeTypeLabel": "Type de nœud", + "nodeTypeHint": "Niveau 2 : typer la scène (lieu, rencontre, PNJ…) colore le nœud dans la vue graphe. Optionnel.", "contextSectionTitle": "Contexte et ambiance", "locationLabel": "Lieu", "locationPlaceholder": "Ex: Taverne du Dragon d'Or", @@ -57,8 +63,10 @@ "branchLabelPlaceholder": "Ex: Si les joueurs attaquent le garde", "branchTargetLabel": "Scène de destination *", "branchTargetPlaceholder": "— Choisir une scène —", + "branchTargetBroken": "Destination introuvable (scène supprimée) : choisissez une nouvelle destination ou retirez cette branche.", "branchConditionLabel": "Condition MJ (optionnel)", "branchConditionPlaceholder": "Ex: Jet de Persuasion DD 15 réussi", + "branchKindLabel": "Type de lien", "branchRemove": "Retirer", "branchRemoveTitle": "Supprimer cette branche", "branchAdd": "+ Ajouter une branche", @@ -114,5 +122,18 @@ "deleteMessage": "Supprimer la scène \"{{name}}\" ?", "deleteIrreversible": "Cette action est irréversible.", "deleteError": "Erreur lors de la suppression de la scène" + }, + "sceneType": { + "GENERIC": "Scène générique", + "LOCATION": "Lieu", + "ENCOUNTER": "Rencontre", + "NPC": "Situation PNJ", + "EVENT": "Événement", + "REVELATION": "Révélation" + }, + "linkType": { + "EXIT": "Sortie", + "CLUE": "Indice", + "LEAD": "Piste" } } diff --git a/web/src/assets/i18n/fragments/session-cockpit.en.json b/web/src/assets/i18n/fragments/session-cockpit.en.json new file mode 100644 index 0000000..42aace3 --- /dev/null +++ b/web/src/assets/i18n/fragments/session-cockpit.en.json @@ -0,0 +1,25 @@ +{ + "sessionReferencePanel": { + "tabPlay": "Play", + "playClocks": "Clocks", + "playFacts": "Facts", + "playNoPlaythrough": "This session is not attached to any playthrough.", + "pinScene": "Pin as current scene" + }, + "sessionDetail": { + "pinned": { + "label": "Current scene", + "open": "Open the scene (new tab)", + "unpin": "Unpin", + "showNarration": "Show player narration", + "hideNarration": "Hide narration" + }, + "recap": { + "button": "“Previously…” recap", + "loading": "Writing the recap…", + "title": "Previously ({{name}})", + "addToJournal": "Add to journal", + "error": "Recap failed. Is the Brain (AI) running?" + } + } +} diff --git a/web/src/assets/i18n/fragments/session-cockpit.fr.json b/web/src/assets/i18n/fragments/session-cockpit.fr.json new file mode 100644 index 0000000..445a617 --- /dev/null +++ b/web/src/assets/i18n/fragments/session-cockpit.fr.json @@ -0,0 +1,25 @@ +{ + "sessionReferencePanel": { + "tabPlay": "Partie", + "playClocks": "Horloges", + "playFacts": "Faits", + "playNoPlaythrough": "Cette session n'est rattachée à aucune Partie.", + "pinScene": "Épingler comme scène courante" + }, + "sessionDetail": { + "pinned": { + "label": "Scène courante", + "open": "Ouvrir la scène (nouvel onglet)", + "unpin": "Retirer l'épingle", + "showNarration": "Afficher la narration joueurs", + "hideNarration": "Masquer la narration" + }, + "recap": { + "button": "Récap « précédemment… »", + "loading": "Rédaction du récap…", + "title": "Précédemment ({{name}})", + "addToJournal": "Consigner au journal", + "error": "Échec du récap. Le Brain (IA) est-il démarré ?" + } + } +} diff --git a/web/src/assets/i18n/fragments/session-prep.en.json b/web/src/assets/i18n/fragments/session-prep.en.json new file mode 100644 index 0000000..de32620 --- /dev/null +++ b/web/src/assets/i18n/fragments/session-prep.en.json @@ -0,0 +1,28 @@ +{ + "playthroughDetail": { + "prepTitle": "Prepare the next session" + }, + "sessionPrep": { + "lastSessionActive": "Session in progress: {{name}}", + "lastSessionEnded": "Last session: {{name}} (ended)", + "allQuiet": "All clear: no active quest and no gap in upcoming content. Start the next session whenever you like.", + "positionTitle": "Where the players are", + "inProgress": "In progress:", + "available": "Available:", + "completed": "Completed:", + "markStarted": "Mark as in progress (the players started it)", + "markCompleted": "Mark as completed (removed from likely leads)", + "reopen": "Reopen (back to not started)", + "hotspotsTitle": "Likely content for the next session", + "nodeKind": { + "CHAPTER": "chapter", + "SCENE": "scene" + }, + "gapsTitle": "To fill in before playing", + "fix": "Fix", + "otherGaps": "+ {{n}} other gap(s) elsewhere in the campaign", + "clocksTitle": "Threats in motion", + "clockNearly": "nearly full!", + "clockFull": "full!" + } +} diff --git a/web/src/assets/i18n/fragments/session-prep.fr.json b/web/src/assets/i18n/fragments/session-prep.fr.json new file mode 100644 index 0000000..cff8476 --- /dev/null +++ b/web/src/assets/i18n/fragments/session-prep.fr.json @@ -0,0 +1,28 @@ +{ + "playthroughDetail": { + "prepTitle": "Préparer la prochaine séance" + }, + "sessionPrep": { + "lastSessionActive": "Séance en cours : {{name}}", + "lastSessionEnded": "Dernière séance : {{name}} (terminée)", + "allQuiet": "Rien à signaler : pas de quête active ni de manque sur le contenu à venir. Lancez la prochaine séance quand vous voulez.", + "positionTitle": "Où en sont les joueurs", + "inProgress": "En cours :", + "available": "Disponibles :", + "completed": "Terminées :", + "markStarted": "Marquer « en cours » (les joueurs l'ont entamée)", + "markCompleted": "Marquer « terminée » (elle sort des pistes probables)", + "reopen": "Rouvrir (revenir à « non commencée »)", + "hotspotsTitle": "Contenu probable de la prochaine séance", + "nodeKind": { + "CHAPTER": "chapitre", + "SCENE": "scène" + }, + "gapsTitle": "À combler avant de jouer", + "fix": "Corriger", + "otherGaps": "+ {{n}} autre(s) manque(s) ailleurs dans la campagne", + "clocksTitle": "Menaces en mouvement", + "clockNearly": "presque pleine !", + "clockFull": "pleine !" + } +} diff --git a/web/src/assets/i18n/fragments/shared-widgets-a.en.json b/web/src/assets/i18n/fragments/shared-widgets-a.en.json index 0d3d30b..cc474c8 100644 --- a/web/src/assets/i18n/fragments/shared-widgets-a.en.json +++ b/web/src/assets/i18n/fragments/shared-widgets-a.en.json @@ -20,7 +20,9 @@ "removeLink": "Remove link", "increment": "One more", "decrement": "One less", - "noMatch": "No matching enemy" + "noMatch": "No matching enemy", + "broken": "Missing reference", + "brokenTitle": "This scene references a deleted bestiary entry (enemy deleted then recreated?). Click × to remove the dead reference." }, "personaView": { "empty": "This sheet is still empty." diff --git a/web/src/assets/i18n/fragments/shared-widgets-a.fr.json b/web/src/assets/i18n/fragments/shared-widgets-a.fr.json index d618eef..13f9e53 100644 --- a/web/src/assets/i18n/fragments/shared-widgets-a.fr.json +++ b/web/src/assets/i18n/fragments/shared-widgets-a.fr.json @@ -20,7 +20,9 @@ "removeLink": "Retirer le lien", "increment": "Un de plus", "decrement": "Un de moins", - "noMatch": "Aucun ennemi ne correspond" + "noMatch": "Aucun ennemi ne correspond", + "broken": "Référence introuvable", + "brokenTitle": "Cette scène référence une fiche du bestiaire supprimée (ennemi supprimé puis recréé ?). Cliquez sur × pour retirer la référence morte." }, "personaView": { "empty": "Cette fiche est encore vide."