From 4f53d903eb49054f2bf8ff5cd1ffec1483c87bc8 Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Wed, 27 May 2026 23:09:43 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20la=20commande=20/gennpc=20pour=20g?= =?UTF-8?q?=C3=A9n=C3=A9rer=20des=20PNJ=20Traveller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémentation complète du générateur de PNJ Traveller basé sur : https://github.com/carloscasalar/traveller-npc-generator Fonctionnalités : - Génération de caractéristiques selon 4 catégories de citoyens - Distribution des compétences selon 6 niveaux d'expérience - 14 rôles différents avec priorités de caractéristiques spécifiques - Génération de noms aléatoires (masculin/féminin/neutre) - Création de fiche d'acteur mgt2e avec toutes les compétences - Interface utilisateur avec dialogue Handlebars - Commande /gennpc dans le chat Fichiers ajoutés : - scripts/data/travellerNpcGenerator.js (données et constantes) - scripts/travellerNpcGenerator.js (logique métier) - scripts/TravellerNpcDialog.js (interface utilisateur) - templates/traveller-npc-dialog.hbs (template dialogue) - templates/traveller-npc-result.hbs (template résultat) - styles/traveller-npc.css (styles spécifiques) Fichiers modifiés : - scripts/npc.js (intégration de la commande) - module.json (ajout des nouveaux scripts et styles) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- module.json | 9 +- scripts/TravellerNpcDialog.js | 309 +++++++++++ scripts/data/travellerNpcGenerator.js | 715 ++++++++++++++++++++++++++ scripts/npc.js | 43 +- scripts/travellerNpcGenerator.js | 663 ++++++++++++++++++++++++ styles/traveller-npc.css | 331 ++++++++++++ templates/traveller-npc-dialog.hbs | 109 ++++ templates/traveller-npc-result.hbs | 65 +++ 8 files changed, 2239 insertions(+), 5 deletions(-) create mode 100644 scripts/TravellerNpcDialog.js create mode 100644 scripts/data/travellerNpcGenerator.js create mode 100644 scripts/travellerNpcGenerator.js create mode 100644 styles/traveller-npc.css create mode 100644 templates/traveller-npc-dialog.hbs create mode 100644 templates/traveller-npc-result.hbs diff --git a/module.json b/module.json index 094be92..f48fe53 100644 --- a/module.json +++ b/module.json @@ -7,14 +7,17 @@ "verified": "13", "maximum": "14" }, - "description": "Module de compendium et d'outils Mongoose Traveller 2e pour FoundryVTT écrit par JdR.Ninja.\nInclut les commandes /commerce, /pnj, /rencontre et /mission pour automatiser le commerce, les PNJ rapides, les rencontres et les contrats aléatoires au-dessus du système mgt2e, en s'appuyant sur les compétences natives des fiches.", + "description": "Module de compendium et d'outils Mongoose Traveller 2e pour FoundryVTT écrit par JdR.Ninja.\nInclut les commandes /commerce, /pnj, /rencontre, /mission et /gennpc pour automatiser le commerce, les PNJ rapides, les rencontres, les contrats aléatoires et la génération de PNJ Traveller selon les règles du générateur officiel, en s'appuyant sur les compétences natives des fiches.", "esmodules": [ "scripts/commerce.js", - "scripts/npc.js" + "scripts/npc.js", + "scripts/TravellerNpcDialog.js", + "scripts/travellerNpcGenerator.js" ], "styles": [ "styles/commerce.css", - "styles/npc.css" + "styles/npc.css", + "styles/traveller-npc.css" ], "packs": [ { diff --git a/scripts/TravellerNpcDialog.js b/scripts/TravellerNpcDialog.js new file mode 100644 index 0000000..2e0c73d --- /dev/null +++ b/scripts/TravellerNpcDialog.js @@ -0,0 +1,309 @@ +/** + * Traveller NPC Generator - Dialogue de génération + * + * Ce fichier contient le dialogue pour générer des PNJ Traveller. + */ + +import { generateAndCreateTravellerNpc } from './travellerNpcGenerator.js'; +import { + CITIZEN_CATEGORY_LIST, + EXPERIENCE_LEVEL_LIST, + ROLE_LIST, + GENDER_LIST, + DEFAULT_OPTIONS, + CHARACTERISTIC_LIST, + UPP_ORDER +} from './data/travellerNpcGenerator.js'; + +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; +const MODULE_ID = 'mgt2-compendium-amiral-denisov'; + +export class TravellerNpcDialog extends HandlebarsApplicationMixin(ApplicationV2) { + static DEFAULT_OPTIONS = { + id: 'mgt2-traveller-npc', + classes: ['mgt2-npc-dialog', 'mgt2-traveller-npc-dialog'], + position: { + width: 700, + height: 'auto', + }, + window: { + title: 'Générateur de PNJ Traveller – MgT2e', + resizable: true, + }, + }; + + static PARTS = { + main: { + template: `modules/${MODULE_ID}/templates/traveller-npc-dialog.hbs`, + root: true, + }, + }; + + constructor(options = {}) { + super(options); + + // Form data avec valeurs par défaut + this._formData = { + citizenCategory: options.citizenCategory || DEFAULT_OPTIONS.citizenCategory, + experience: options.experience || DEFAULT_OPTIONS.experience, + role: options.role || DEFAULT_OPTIONS.role, + gender: options.gender || DEFAULT_OPTIONS.gender, + firstName: options.firstName || '', + surname: options.surname || '', + useRandomName: options.useRandomName !== false, // Par défaut, on utilise des noms aléatoires + createActor: options.createActor !== undefined ? options.createActor : DEFAULT_OPTIONS.createActor, + actorName: options.actorName || '', + openCreatedActor: options.openCreatedActor !== undefined ? options.openCreatedActor : DEFAULT_OPTIONS.openCreatedActor, + }; + } + + async _prepareContext() { + registerHandlebarsHelpers(); + return { + ...this._formData, + citizenCategories: CITIZEN_CATEGORY_LIST.map(c => ({ + key: c.key, + label: c.label, + description: c.description + })), + experienceLevels: EXPERIENCE_LEVEL_LIST.map(e => ({ + key: e.key, + label: e.label, + description: e.description + })), + roles: ROLE_LIST.map(r => ({ + key: r.key, + label: r.label, + description: r.description + })), + genders: GENDER_LIST.map(g => ({ + key: g.key, + label: g.label + })) + }; + } + + async _onRender(context, options) { + await super._onRender(context, options); + const html = this._getForm(); + if (!html?.length) return; + + html.addClass('mgt2-traveller-npc-form'); + this._applyThemeStyles(html); + + // Gestion des événements + html.find('[data-action="generate-traveller-npc"]').on('click', async (event) => { + event.preventDefault(); + this._readForm(html); + await this._handleGenerate(); + }); + + html.find('[data-action="randomize-name"]').on('click', (event) => { + event.preventDefault(); + this._randomizeName(html); + }); + + // Gestion du basculement entre nom aléatoire et nom personnalisé + html.find('[name="useRandomName"]').on('change', (event) => { + const useRandom = event.target.checked; + html.find('.name-fields').toggleClass('hidden', useRandom); + }); + + // Initialiser l'affichage des champs de nom + html.find('.name-fields').toggleClass('hidden', this._formData.useRandomName); + } + + _getForm() { + return $(this.element).find('.window-content'); + } + + _applyThemeStyles(html) { + // Appliquer les styles de thème cohérents avec le dialogue existant + html.find('.tabs .item').css({ + color: '#d8c79a', + 'text-shadow': 'none', + 'background-color': '', + 'border-bottom-color': 'transparent' + }); + + html.find('.tabs .item.active').css({ + color: '#d9b24c', + 'text-shadow': 'none', + 'background-color': 'rgba(201, 162, 39, 0.18)', + 'border-bottom-color': '#c9a227' + }); + + html.find('h3').css({ + color: '#5f4300', + 'border-bottom-color': '#b78f26', + 'text-shadow': 'none' + }); + + html.find('legend').css({ + color: '#7a5c00', + 'text-shadow': 'none' + }); + } + + _readForm(html) { + this._formData.citizenCategory = html.find('[name="citizenCategory"]').val(); + this._formData.experience = html.find('[name="experience"]').val(); + this._formData.role = html.find('[name="role"]').val(); + this._formData.gender = html.find('[name="gender"]').val(); + this._formData.firstName = html.find('[name="firstName"]').val(); + this._formData.surname = html.find('[name="surname"]').val(); + this._formData.useRandomName = html.find('[name="useRandomName"]').is(':checked'); + this._formData.createActor = html.find('[name="createActor"]').is(':checked'); + this._formData.actorName = html.find('[name="actorName"]').val(); + this._formData.openCreatedActor = html.find('[name="openCreatedActor"]').is(':checked'); + } + + _randomizeName(html) { + // Importer dynamiquement pour éviter les dépendances circulaires + import('./data/travellerNpcGenerator.js').then(module => { + const name = module.generateRandomName(this._formData.gender); + html.find('[name="firstName"]').val(name.firstName); + html.find('[name="surname"]').val(name.surname); + this._formData.firstName = name.firstName; + this._formData.surname = name.surname; + this._formData.useRandomName = false; + html.find('[name="useRandomName"]').prop('checked', false); + html.find('.name-fields').removeClass('hidden'); + }); + } + + async _handleGenerate() { + const button = $(this.element).find('[data-action="generate-traveller-npc"]'); + const originalLabel = button.html(); + + try { + // Désactiver le bouton pendant la génération + button.prop('disabled', true).html(' Génération...'); + + // Préparer les options de génération + const generateOptions = { + citizenCategory: this._formData.citizenCategory, + experience: this._formData.experience, + role: this._formData.role, + gender: this._formData.gender, + createActor: this._formData.createActor, + actorName: this._formData.actorName, + openCreatedActor: this._formData.openCreatedActor + }; + + // Si on n'utilise pas de nom aléatoire, passer le nom personnalisé + if (!this._formData.useRandomName && this._formData.firstName && this._formData.surname) { + generateOptions.firstName = this._formData.firstName; + generateOptions.surname = this._formData.surname; + } + + // Générer le PNJ + const result = await generateAndCreateTravellerNpc(generateOptions); + + if (result.success) { + // Afficher le résultat dans le chat + await this._postToChatResult(result); + + if (result.createdActor) { + ui.notifications.info(`Fiche PNJ Traveller créée : ${result.createdActor.name}`); + } + } else { + ui.notifications.error('Erreur lors de la génération du PNJ Traveller'); + } + } catch (error) { + console.error(`${MODULE_ID} | Erreur lors de la génération du PNJ Traveller:`, error); + ui.notifications.error(`Erreur: ${error.message}`); + } finally { + // Réactiver le bouton + button.prop('disabled', false).html(originalLabel); + } + } + + async _postToChatResult(data) { + registerHandlebarsHelpers(); + const html = await foundry.applications.handlebars.renderTemplate( + `modules/${MODULE_ID}/templates/traveller-npc-result.hbs`, + data + ); + + await ChatMessage.create({ + content: html, + speaker: ChatMessage.getSpeaker(), + flags: { [MODULE_ID]: { type: 'traveller-npc-result' } }, + }); + } +} + +// ============================================================================ +// Helper functions +// ============================================================================ + +let helpersRegistered = false; + +function registerHandlebarsHelpers() { + if (helpersRegistered) return; + helpersRegistered = true; + + // Helper pour comparer deux valeurs + Handlebars.registerHelper('eq', (a, b) => a === b); + + // Helper pour rejoindre un tableau + Handlebars.registerHelper('join', (arr, sep) => (Array.isArray(arr) ? arr.join(sep) : '')); + + // Helper pour vérifier si une valeur contient du texte + Handlebars.registerHelper('contains', (text, search) => String(text ?? '').includes(search)); + + // Helper pour vérifier si a > b + Handlebars.registerHelper('gt', (a, b) => a > b); + + // Helper pour afficher le niveau de compétence avec un symbole + Handlebars.registerHelper('skillLevelSymbol', (level) => { + if (level === 0) return ''; + if (level === 1) return '★'; + if (level === 2) return '★★'; + if (level === 3) return '★★★'; + return `+${level}`; + }); + + // Helper pour formater le DM + Handlebars.registerHelper('formatDm', (value) => { + const dm = Math.floor((value - 6) / 3); + return dm >= 0 ? `+${dm}` : `${dm}`; + }); + + // Helper pour obtenir la classe CSS du niveau de compétence + Handlebars.registerHelper('skillLevelClass', (level) => { + if (level === 3) return 'skill-level-3'; + if (level === 2) return 'skill-level-2'; + if (level === 1) return 'skill-level-1'; + return 'skill-level-0'; + }); + + // Helper pour formater une compétence avec son niveau + Handlebars.registerHelper('formatSkillForDisplay', (name, level) => { + if (level === 0) { + return name; + } + return `${name}-${level}`; + }); + + // Helper pour créer un objet de libellés de caractéristiques + Handlebars.registerHelper('createCharacteristicLabels', () => { + const labels = {}; + CHARACTERISTIC_LIST.forEach(char => { + labels[char.key] = char.label; + }); + return labels; + }); + + // Helper pour lookup dans un objet + Handlebars.registerHelper('lookup', (obj, key) => { + if (!obj || !key) return ''; + return obj[key] !== undefined ? obj[key] : ''; + }); +} + +// Exporter pour pouvoir l'ouvrir depuis d'autres modules +export function openTravellerNpcDialog(options = {}) { + new TravellerNpcDialog(options).render({ force: true }); +} diff --git a/scripts/data/travellerNpcGenerator.js b/scripts/data/travellerNpcGenerator.js new file mode 100644 index 0000000..7713271 --- /dev/null +++ b/scripts/data/travellerNpcGenerator.js @@ -0,0 +1,715 @@ +/** + * Traveller NPC Generator - Données de configuration + * Basé sur : https://github.com/carloscasalar/traveller-npc-generator + * + * Ce fichier contient toutes les données nécessaires pour générer des PNJ + * selon les règles du générateur Traveller. + */ + +const MODULE_ID = 'mgt2-compendium-amiral-denisov'; + +// ============================================================================ +// Catégories de citoyens +// ============================================================================ + +export const CITIZEN_CATEGORY = { + BELOW_AVERAGE: { + key: 'belowAverage', + label: 'En dessous de la moyenne', + value: 0, + characteristicArray: [8, 7, 6, 6, 5, 4], + description: 'Citoyen avec des capacités inférieures à la moyenne' + }, + AVERAGE: { + key: 'average', + label: 'Moyenne', + value: 1, + characteristicArray: [9, 8, 7, 7, 6, 5], + description: 'Citoyen moyen' + }, + ABOVE_AVERAGE: { + key: 'aboveAverage', + label: 'Au-dessus de la moyenne', + value: 2, + characteristicArray: [10, 9, 8, 8, 7, 6], + description: 'Citoyen avec des capacités supérieures à la moyenne' + }, + EXCEPTIONAL: { + key: 'exceptional', + label: 'Exceptionnel', + value: 3, + characteristicArray: [11, 10, 9, 9, 8, 7], + description: 'Citoyen exceptionnel' + } +}; + +export const CITIZEN_CATEGORY_LIST = [ + CITIZEN_CATEGORY.BELOW_AVERAGE, + CITIZEN_CATEGORY.AVERAGE, + CITIZEN_CATEGORY.ABOVE_AVERAGE, + CITIZEN_CATEGORY.EXCEPTIONAL +]; + +// ============================================================================ +// Niveaux d'expérience +// ============================================================================ + +export const EXPERIENCE_LEVEL = { + RECRUIT: { + key: 'recruit', + label: 'Recrue', + value: 0, + skillDistribution: { + level0: 4, + level1: 0, + level2: 0, + level3: 0 + }, + description: 'Nouveau, sans expérience' + }, + ROOKIE: { + key: 'rookie', + label: 'Débutant', + value: 1, + skillDistribution: { + level0: 4, + level1: 2, + level2: 0, + level3: 0 + }, + description: 'Débutant avec un peu d\'expérience' + }, + INTERMEDIATE: { + key: 'intermediate', + label: 'Intermédiaire', + value: 2, + skillDistribution: { + level0: 4, + level1: 2, + level2: 1, + level3: 0 + }, + description: 'Niveau intermédiaire' + }, + REGULAR: { + key: 'regular', + label: 'Régulier', + value: 3, + skillDistribution: { + level0: 5, + level1: 2, + level2: 2, + level3: 0 + }, + description: 'Expérience régulière' + }, + VETERAN: { + key: 'veteran', + label: 'Vétéran', + value: 4, + skillDistribution: { + level0: 5, + level1: 2, + level2: 3, + level3: 0 + }, + description: 'Vétéran expérimenté' + }, + ELITE: { + key: 'elite', + label: 'Élite', + value: 5, + skillDistribution: { + level0: 6, + level1: 3, + level2: 2, + level3: 1 + }, + description: 'Élite, très expérimenté' + } +}; + +export const EXPERIENCE_LEVEL_LIST = [ + EXPERIENCE_LEVEL.RECRUIT, + EXPERIENCE_LEVEL.ROOKIE, + EXPERIENCE_LEVEL.INTERMEDIATE, + EXPERIENCE_LEVEL.REGULAR, + EXPERIENCE_LEVEL.VETERAN, + EXPERIENCE_LEVEL.ELITE +]; + +// ============================================================================ +// Rôles (Crew roles in a starship) +// ============================================================================ + +// Caractéristiques par rôle - définit quelles caractéristiques sont prioritaires +// pour chaque rôle (High, Medium, Low) +export const CHARACTERISTIC_PRIORITIES = { + pilot: { + high: ['DEX', 'INT'], + medium: ['EDU', 'STR'], + low: ['END', 'SOC'] + }, + navigator: { + high: ['INT', 'EDU'], + medium: ['DEX', 'SOC'], + low: ['STR', 'END'] + }, + engineer: { + high: ['INT', 'EDU'], + medium: ['DEX', 'END'], + low: ['STR', 'SOC'] + }, + steward: { + high: ['INT', 'SOC'], + medium: ['DEX', 'EDU'], + low: ['STR', 'END'] + }, + medic: { + high: ['INT', 'EDU'], + medium: ['DEX', 'SOC'], + low: ['STR', 'END'] + }, + marine: { + high: ['STR', 'END'], + medium: ['DEX', 'INT'], + low: ['EDU', 'SOC'] + }, + gunner: { + high: ['DEX', 'INT'], + medium: ['END', 'EDU'], + low: ['STR', 'SOC'] + }, + scout: { + high: ['DEX', 'INT'], + medium: ['END', 'EDU'], + low: ['STR', 'SOC'] + }, + technician: { + high: ['INT', 'EDU'], + medium: ['DEX', 'END'], + low: ['STR', 'SOC'] + }, + leader: { + high: ['INT', 'SOC'], + medium: ['EDU', 'END'], + low: ['DEX', 'STR'] + }, + diplomat: { + high: ['INT', 'SOC'], + medium: ['EDU', 'DEX'], + low: ['STR', 'END'] + }, + entertainer: { + high: ['DEX', 'SOC'], + medium: ['INT', 'EDU'], + low: ['STR', 'END'] + }, + trader: { + high: ['INT', 'SOC'], + medium: ['EDU', 'DEX'], + low: ['STR', 'END'] + }, + thug: { + high: ['STR', 'END'], + medium: ['DEX', 'INT'], + low: ['EDU', 'SOC'] + } +}; + +// Compétences pertinentes pour chaque rôle +export const ROLE_SKILLS = { + pilot: [ + 'Pilot-Spacecraft', + 'Astrogation', + 'Electronics-Sensors', + 'Gunner', + 'Mechanic', + 'Pilot-Small Craft', + 'Leadership', + 'Vacc Suit', + 'Communications', + 'Drive-Grav', + 'Survival', + 'Recon', + 'Flyer' + ], + navigator: [ + 'Astrogation', + 'Electronics-Sensors', + 'Pilot-Spacecraft', + 'Computers', + 'Survival', + 'Navigation', + 'Mechanic', + 'Leadership', + 'Tactics', + 'Engineer', + 'Vacc Suit', + 'Recon' + ], + engineer: [ + 'Engineer-MDrive', + 'Mechanic', + 'Engineer-Power', + 'Computers', + 'Engineer-JDrive', + 'Engineer-Life Support', + 'Electronics-Sensors', + 'Survival', + 'Pilot-Small Craft', + 'Leadership', + 'Vacc Suit', + 'Recon', + 'Drive' + ], + steward: [ + 'Steward', + 'Carouse', + 'Persuade', + 'Broker', + 'Admin', + 'Computers', + 'Language', + 'Advocate', + 'Leadership', + 'Medic', + 'Streetwise', + 'Diplomat' + ], + medic: [ + 'Medic', + 'Science-Biology', + 'Science-Chemistry', + 'Deception', + 'Investigate', + 'Diplomat', + 'Computers', + 'Persuade', + 'Admin', + 'Broker', + 'Electronics-Sensors', + 'Drive', + 'Leadership' + ], + marine: [ + 'Gun Combat', + 'Survival', + 'Athletics-Strength', + 'Melee-Unarmed', + 'Heavy Weapons', + 'Tactics', + 'Recon', + 'Electronics-Sensors', + 'Leadership', + 'Medic', + 'Drive-Grav', + 'Communications', + 'Stealth' + ], + gunner: [ + 'Gunner-Turrets', + 'Electronics-Sensors', + 'Gunner-Screens', + 'Tactics', + 'Gun Combat', + 'Leadership', + 'Mechanic', + 'Heavy Weapons', + 'Explosives', + 'Computers', + 'Pilot-Small Craft', + 'Athletics-Dexterity', + 'Melee-Blade' + ], + scout: [ + 'Survival', + 'Recon', + 'Pilot-Small Craft', + 'Astrogation', + 'Electronics-Sensors', + 'Stealth', + 'Gunner', + 'Medic', + 'Tactics', + 'Gun Combat', + 'Navigation', + 'Leadership' + ], + technician: [ + 'Mechanic', + 'Computers', + 'Electronics-Sensors', + 'Engineer-Power', + 'Engineer-MDrive', + 'Drive', + 'Pilot', + 'Vacc Suit', + 'Recon', + 'Athletics-Dexterity', + 'Survival', + 'Explosives' + ], + leader: [ + 'Leadership', + 'Tactics', + 'Admin', + 'Diplomat', + 'Persuade', + 'Advocate', + 'Electronics-Sensors', + 'Computers', + 'Deception', + 'Pilot-Spacecraft', + 'Engineer', + 'Recon', + 'Medic' + ], + diplomat: [ + 'Diplomat', + 'Persuade', + 'Advocate', + 'Admin', + 'Carouse', + 'Steward', + 'Streetwise', + 'Language', + 'Broker', + 'Leadership', + 'Communications', + 'Tactics' + ], + entertainer: [ + 'Carouse', + 'Streetwise', + 'Art-Instrument', + 'Persuade', + 'Stealth', + 'Deception', + 'Diplomat', + 'Art-Acting', + 'Computers', + 'Electronics-Sensors', + 'Leadership', + 'Broker', + 'Melee-Blade', + 'Admin' + ], + trader: [ + 'Broker', + 'Persuade', + 'Admin', + 'Advocate', + 'Computers', + 'Streetwise', + 'Gun Combat', + 'Diplomat', + 'Deception', + 'Carouse', + 'Communications', + 'Mechanic', + 'Electronics-Sensors', + 'Leadership' + ], + thug: [ + 'Melee-Unarmed', + 'Gun Combat', + 'Melee-Blade', + 'Athletics-Strength', + 'Stealth', + 'Streetwise', + 'Carouse', + 'Tactics', + 'Stealth', + 'Survival', + 'Persuade', + 'Explosives', + 'Computers' + ] +}; + +// Liste des rôles avec libellés en français +export const ROLE = { + PILOT: { key: 'pilot', label: 'Pilote', description: 'Pilote de vaisseau spatial' }, + NAVIGATOR: { key: 'navigator', label: 'Navigateur', description: 'Navigateur spatial' }, + ENGINEER: { key: 'engineer', label: 'Ingénieur', description: 'Ingénieur de bord' }, + STEWARD: { key: 'steward', label: 'Intendant', description: 'Intendant / steward' }, + MEDIC: { key: 'medic', label: 'Médecin', description: 'Médecin de bord' }, + MARINE: { key: 'marine', label: 'Marine', description: 'Marine / soldat' }, + GUNNER: { key: 'gunner', label: 'Artilleur', description: 'Artilleur / canonnier' }, + SCOUT: { key: 'scout', label: 'Éclaireur', description: 'Éclaireur' }, + TECHNICIAN: { key: 'technician', label: 'Technicien', description: 'Technicien' }, + LEADER: { key: 'leader', label: 'Chef', description: 'Chef / leader' }, + DIPLOMAT: { key: 'diplomat', label: 'Diplomate', description: 'Diplomate' }, + ENTERTAINER: { key: 'entertainer', label: 'Artiste', description: 'Artiste / divertisseur' }, + TRADER: { key: 'trader', label: 'Marchand', description: 'Marchand / commerçant' }, + THUG: { key: 'thug', label: 'Brute', description: 'Brute / voyou' } +}; + +export const ROLE_LIST = [ + ROLE.PILOT, + ROLE.NAVIGATOR, + ROLE.ENGINEER, + ROLE.STEWARD, + ROLE.MEDIC, + ROLE.MARINE, + ROLE.GUNNER, + ROLE.SCOUT, + ROLE.TECHNICIAN, + ROLE.LEADER, + ROLE.DIPLOMAT, + ROLE.ENTERTAINER, + ROLE.TRADER, + ROLE.THUG +]; + +// ============================================================================ +// Genre +// ============================================================================ + +export const GENDER = { + UNSPECIFIED: { key: 'unspecified', label: 'Non spécifié', value: 0 }, + FEMALE: { key: 'female', label: 'Féminin', value: 1 }, + MALE: { key: 'male', label: 'Masculin', value: 2 } +}; + +export const GENDER_LIST = [ + GENDER.UNSPECIFIED, + GENDER.FEMALE, + GENDER.MALE +]; + +// ============================================================================ +// Catalogues de noms +// ============================================================================ + +export const NAME_CATALOGS = { + surnames: [ + 'Anderson', 'Berezovsky', 'Brown', 'Chen', 'Clark', 'Davis', 'Fujita', 'Garcia', + 'Gupta', 'Harris', 'Hicks', 'Ito', 'Ivanov', 'Jackson', 'Johnson', 'Jones', + 'Kim', 'Kobayashi', 'Kowalski', 'Kumar', 'Kuznetsoff', 'Kuznetsov', 'Kuznetsova', + 'Lee', 'Martin', 'Martinez', 'Miller', 'Moore', 'Nakamura', 'Nguyen', 'Nowak', + "O'Brien", "O'Callaghan", "O'Connell", "O'Connor", "O'Keefe", "O'Leary", + "O'Malley", "O'Neil", "O'Reilly", "O'Sullivan", 'Park', 'Patel', 'Pierzynski', + 'Pietrzykowski', 'Pisarski', 'Reshevsky', 'Robinson', 'Rumkowska', 'Saito', + 'Singh', 'Smith', 'Tanaka', 'Taylor', 'Thomas', 'Thompson', 'Vasquez', + 'Watanabe', 'White', 'Williams', 'Wilson', 'Wong', 'Yamamoto', 'Yang' + ], + nonGenderedNames: [ + 'Arrow', 'Artemis', 'Ash', 'Aster', 'Avery', 'Basil', 'Ever', 'Fig', 'Finch', + 'Indigo', 'Jett', 'Juniper', 'Kavi', 'Kaviya', 'Kaviyan', 'Kaviyanan', 'Kay', + 'Lark', 'Noah', 'Ocean', 'Phoenix', 'Riley', 'River', 'Rory', 'Rowan', 'Sage', + 'Sawyer', 'Shiloh', 'Sparrow', 'Sutton', 'Tavi', 'Uli', 'Veer', 'Vesper', + 'Winter', 'Wren', 'Zen', 'Zenith', 'Zephyr', 'Zephyrus' + ], + femaleNames: [ + 'Aarohi', 'Aarushi', 'Abigail', 'Amelia', 'Ananya', 'Anika', 'Anjali', + 'Anushka', 'Aria', 'Ava', 'Chloe', 'Devi', 'Elina', 'Elizabeth', 'Emily', + 'Emma', 'Esha', 'Evelyn', 'Grace', 'Harper', 'Isabella', 'Ishani', 'Ishika', + 'Ishita', 'Layla', 'Lily', 'Madison', 'Margarita', 'Maria', 'Mia', 'Nisha', + 'Nora', 'Nosheen', 'Olivia', 'Penelope', 'Priya', 'Qadira', 'Riley', 'Scarlett', + 'Sofia', 'Sophia', 'Tala', 'Ulka', 'Uthra', 'Uthraa', 'Victoria', 'Yasmin', + 'Zara', 'Zoey', 'Zoya', 'Zulaikha' + ], + maleNames: [ + 'Ahmed', 'Aiden', 'Alexander', 'Benjamin', 'Callum', 'Carter', 'Daniel', + 'David', 'Dylan', 'Elias', 'Elijah', 'Ethan', 'Ewan', 'Ezra', 'Gavin', + 'Henry', 'Hudson', 'Jackson', 'Jacob', 'Jaden', 'James', 'Jaxon', 'Jayden', + 'Jordan', 'Joseph', 'Julian', 'Kian', 'Kianan', 'Kianu', 'Lachlan', 'Leo', + 'Levi', 'Liam', 'Lincoln', 'Logan', 'Lucas', 'Mason', 'Mateo', 'Matthew', + 'Michael', 'Mohammed', 'Noah', 'Nolan', 'Oliver', 'Pol', 'Samuel', + 'Santiago', 'Sebastian', 'Theodore', 'Ulric', 'Wallid', 'William', 'Wyatt' + ] +}; + +// ============================================================================ +// Caractéristiques +// ============================================================================ + +export const CHARACTERISTIC = { + STR: { key: 'STR', label: 'Force', mgt2eKey: 'STR' }, + DEX: { key: 'DEX', label: 'Dextérité', mgt2eKey: 'DEX' }, + END: { key: 'END', label: 'Endurance', mgt2eKey: 'END' }, + INT: { key: 'INT', label: 'Intellect', mgt2eKey: 'INT' }, + EDU: { key: 'EDU', label: 'Éducation', mgt2eKey: 'EDU' }, + SOC: { key: 'SOC', label: 'Statut Social', mgt2eKey: 'SOC' } +}; + +export const CHARACTERISTIC_LIST = [ + CHARACTERISTIC.STR, + CHARACTERISTIC.DEX, + CHARACTERISTIC.END, + CHARACTERISTIC.INT, + CHARACTERISTIC.EDU, + CHARACTERISTIC.SOC +]; + +// Ordre des caractéristiques pour l'UPP +export const UPP_ORDER = ['STR', 'DEX', 'END', 'INT', 'EDU', 'SOC']; + +// ============================================================================ +// Fonctions utilitaires +// ============================================================================ + +/** + * Convertit une valeur de caractéristique en code hexadécimal pour UPP + * @param {number} value - Valeur de la caractéristique (0-15) + * @returns {string} - Code hexadécimal + */ +export function toHex(value) { + return Math.max(0, Math.min(15, Math.floor(value))).toString(16).toUpperCase(); +} + +/** + * Calcule le DM (Damage Modifier) à partir d'une valeur de caractéristique + * @param {number} value - Valeur de la caractéristique + * @returns {number} - Modificateur de dégâts + */ +export function calculateDm(value) { + return Math.floor((value - 6) / 3); +} + +/** + * Génère un nom aléatoire à partir des catalogues + * @param {string} genderKey - Clé du genre ('unspecified', 'female', 'male') + * @returns {{firstName: string, surname: string, fullName: string}} + */ +export function generateRandomName(genderKey = 'unspecified') { + const genderMap = { + unspecified: NAME_CATALOGS.nonGenderedNames, + female: NAME_CATALOGS.femaleNames, + male: NAME_CATALOGS.maleNames + }; + + const firstNames = genderMap[genderKey] || NAME_CATALOGS.nonGenderedNames; + const surnames = NAME_CATALOGS.surnames; + + const firstName = pickRandomItem(firstNames); + const surname = pickRandomItem(surnames); + + return { + firstName, + surname, + fullName: `${firstName} ${surname}` + }; +} + +/** + * Sélectionne un élément aléatoire dans un tableau + * @template T + * @param {T[]} items - Tableau d'éléments + * @returns {T} - Élément sélectionné + */ +export function pickRandomItem(items) { + if (!items || items.length === 0) { + throw new Error('Cannot pick from empty array'); + } + const index = Math.floor(Math.random() * items.length); + return items[index]; +} + +/** + * Mélange un tableau (algorithme de Fisher-Yates) + * @template T + * @param {T[]} array - Tableau à mélanger + * @returns {T[]} - Nouveau tableau mélangé + */ +export function shuffleArray(array) { + const newArray = [...array]; + for (let i = newArray.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; + } + return newArray; +} + +/** + * Extraire les N premiers éléments d'un tableau et retourner les deux parties + * @template T + * @param {T[]} array - Tableau source + * @param {number} count - Nombre d'éléments à extraire + * @returns {[T[], T[]]} - [éléments extraits, éléments restants] + */ +export function popRandomItems(array, count) { + if (!array || array.length === 0 || count <= 0) { + return [[], [...array]]; + } + + const shuffled = shuffleArray(array); + const taken = shuffled.slice(0, Math.min(count, shuffled.length)); + const remaining = shuffled.slice(Math.min(count, shuffled.length)); + + return [taken, remaining]; +} + +/** + * Trouve un rôle par sa clé + * @param {string} key - Clé du rôle + * @returns {Object|null} - Objet rôle ou null + */ +export function getRoleByKey(key) { + const found = ROLE_LIST.find(r => r.key === key); + return found || ROLE.PILOT; +} + +/** + * Trouve une catégorie de citoyen par sa clé + * @param {string} key - Clé de la catégorie + * @returns {Object} - Objet catégorie + */ +export function getCitizenCategoryByKey(key) { + const found = CITIZEN_CATEGORY_LIST.find(c => c.key === key); + return found || CITIZEN_CATEGORY.AVERAGE; +} + +/** + * Trouve un niveau d'expérience par sa clé + * @param {string} key - Clé du niveau + * @returns {Object} - Objet niveau d'expérience + */ +export function getExperienceLevelByKey(key) { + const found = EXPERIENCE_LEVEL_LIST.find(e => e.key === key); + return found || EXPERIENCE_LEVEL.REGULAR; +} + +/** + * Trouve un genre par sa clé + * @param {string} key - Clé du genre + * @returns {Object} - Objet genre + */ +export function getGenderByKey(key) { + const found = GENDER_LIST.find(g => g.key === key); + return found || GENDER.UNSPECIFIED; +} + +/** + * Obtient les compétences pour un rôle + * @param {string} roleKey - Clé du rôle + * @returns {string[]} - Tableau de compétences + */ +export function getSkillsForRole(roleKey) { + return ROLE_SKILLS[roleKey] || ROLE_SKILLS.pilot; +} + +/** + * Obtient les priorités de caractéristiques pour un rôle + * @param {string} roleKey - Clé du rôle + * @returns {Object} - Priorités de caractéristiques + */ +export function getCharacteristicPrioritiesForRole(roleKey) { + return CHARACTERISTIC_PRIORITIES[roleKey] || CHARACTERISTIC_PRIORITIES.pilot; +} + +// ============================================================================ +// Données par défaut +// ============================================================================ + +export const DEFAULT_OPTIONS = { + citizenCategory: CITIZEN_CATEGORY.AVERAGE.key, + experience: EXPERIENCE_LEVEL.REGULAR.key, + role: ROLE.PILOT.key, + gender: GENDER.UNSPECIFIED.key, + createActor: false, + actorName: '', + openCreatedActor: true +}; diff --git a/scripts/npc.js b/scripts/npc.js index 7510738..80328f9 100644 --- a/scripts/npc.js +++ b/scripts/npc.js @@ -1,4 +1,5 @@ import { NpcDialog } from './NpcDialog.js'; +import { TravellerNpcDialog, openTravellerNpcDialog } from './TravellerNpcDialog.js'; import { syncNpcRollTables } from './npcRollTableSync.js'; import './mgt2eMigration.js'; @@ -9,6 +10,10 @@ function openNpcDialog(initialTab, options = {}) { new NpcDialog({ initialTab, ...options }).render({ force: true }); } +function openTravellerNpcGenerator() { + openTravellerNpcDialog(); +} + function registerNpcCommand(commandName, initialTab) { if (!ChatLogV2?.CHAT_COMMANDS) { console.warn(`${MODULE_ID} | ChatLog.CHAT_COMMANDS indisponible, commande /${commandName} non enregistrée`); @@ -25,6 +30,22 @@ function registerNpcCommand(commandName, initialTab) { console.log(`${MODULE_ID} | Commande /${commandName} enregistrée via ChatLog.CHAT_COMMANDS`); } +function registerTravellerNpcCommand() { + if (!ChatLogV2?.CHAT_COMMANDS) { + console.warn(`${MODULE_ID} | ChatLog.CHAT_COMMANDS indisponible, commande /gennpc non enregistrée`); + return; + } + + ChatLogV2.CHAT_COMMANDS.gennpc = { + rgx: new RegExp(`^\\/gennpc(?:\\s+(.*))?$`, 'i'), + fn: () => { + openTravellerNpcGenerator(); + return false; + }, + }; + console.log(`${MODULE_ID} | Commande /gennpc enregistrée via ChatLog.CHAT_COMMANDS`); +} + Hooks.once('init', () => { console.log(`${MODULE_ID} | Outils PNJ initialisés`); @@ -35,17 +56,20 @@ Hooks.once('init', () => { loadTemplatesFn([ `modules/${MODULE_ID}/templates/npc-dialog.hbs`, `modules/${MODULE_ID}/templates/npc-result.hbs`, + `modules/${MODULE_ID}/templates/traveller-npc-dialog.hbs`, + `modules/${MODULE_ID}/templates/traveller-npc-result.hbs`, ]); } registerNpcCommand('pnj', 'npc'); registerNpcCommand('rencontre', 'encounter'); registerNpcCommand('mission', 'mission'); + registerTravellerNpcCommand(); }); Hooks.once('ready', async () => { await syncNpcRollTables(); - console.log(`${MODULE_ID} | Outils PNJ prêts – tapez /pnj, /rencontre ou /mission dans le chat`); + console.log(`${MODULE_ID} | Outils PNJ prêts – tapez /pnj, /rencontre, /mission ou /gennpc dans le chat`); }); /** @@ -91,13 +115,18 @@ Hooks.on('renderChatInput', (app, html, data) => { event.stopImmediatePropagation(); openNpcDialog('mission'); input.val(''); + } else if (content?.startsWith('/gennpc')) { + event.preventDefault(); + event.stopImmediatePropagation(); + openTravellerNpcGenerator(); + input.val(''); } } }); }); /** - * Intercepte les messages de chat pour /pnj, /rencontre, /mission + * Intercepte les messages de chat pour /pnj, /rencontre, /mission, /gennpc * Utilise preCreateChatMessage pour Foundry v14+ (avant que le message ne soit validé) * Compatible avec Foundry v13 et v14 */ @@ -118,6 +147,11 @@ Hooks.on('preCreateChatMessage', (message, data, options) => { openNpcDialog('mission'); return false; // Empêche la création du message } + + if (content === '/gennpc' || content?.startsWith('/gennpc ')) { + openTravellerNpcGenerator(); + return false; // Empêche la création du message + } }); // Gardé pour compatibilité v13 @@ -152,4 +186,9 @@ Hooks.on('chatMessage', (...args) => { openNpcDialog('mission'); return false; } + + if (trimmed === '/gennpc' || trimmed?.startsWith('/gennpc ')) { + openTravellerNpcGenerator(); + return false; + } }); diff --git a/scripts/travellerNpcGenerator.js b/scripts/travellerNpcGenerator.js new file mode 100644 index 0000000..1edc791 --- /dev/null +++ b/scripts/travellerNpcGenerator.js @@ -0,0 +1,663 @@ +/** + * Traveller NPC Generator - Logique métier + * Basé sur : https://github.com/carloscasalar/traveller-npc-generator + * + * Ce fichier contient la logique de génération des PNJ Traveller. + */ + +import { + CITIZEN_CATEGORY, + CITIZEN_CATEGORY_LIST, + EXPERIENCE_LEVEL, + EXPERIENCE_LEVEL_LIST, + ROLE, + ROLE_LIST, + ROLE_SKILLS, + CHARACTERISTIC_PRIORITIES, + GENDER, + GENDER_LIST, + CHARACTERISTIC, + CHARACTERISTIC_LIST, + UPP_ORDER, + NAME_CATALOGS, + toHex, + calculateDm, + pickRandomItem, + popRandomItems, + shuffleArray, + getRoleByKey, + getCitizenCategoryByKey, + getExperienceLevelByKey, + getGenderByKey, + getSkillsForRole, + getCharacteristicPrioritiesForRole, + DEFAULT_OPTIONS +} from './data/travellerNpcGenerator.js'; + +import { setSkillLevel, localizeSkill } from './mgt2eSkills.js'; + +const MODULE_ID = 'mgt2-compendium-amiral-denisov'; + +// Cache pour le système de base des acteurs mgt2e +let mgt2eBaseActorSystemPromise = null; + +// ============================================================================ +// Génération des caractéristiques +// ============================================================================ + +/** + * Génère les caractéristiques d'un PNJ en fonction de sa catégorie et de son rôle + * + * @param {string} citizenCategoryKey - Clé de la catégorie de citoyen + * @param {string} roleKey - Clé du rôle + * @returns {Object} - Objet contenant les caractéristiques et l'UPP + */ +export function generateCharacteristics(citizenCategoryKey, roleKey) { + const category = getCitizenCategoryByKey(citizenCategoryKey); + const priorities = getCharacteristicPrioritiesForRole(roleKey); + + // On commence avec l'array de base de la catégorie + let characteristicArray = [...category.characteristicArray]; + + // On attribue les valeurs aux caractéristiques selon les priorités du rôle + const characteristics = {}; + + // 1. Attribuer les valeurs les plus élevées aux caractéristiques High priority + const [highValues, remaining1] = popRandomItems(characteristicArray, priorities.high.length); + priorities.high.forEach((charKey, index) => { + characteristics[charKey] = highValues[index] || 7; + }); + + // 2. Attribuer les valeurs moyennes aux caractéristiques Medium priority + const [mediumValues, remaining2] = popRandomItems(remaining1, priorities.medium.length); + priorities.medium.forEach((charKey, index) => { + characteristics[charKey] = mediumValues[index] || 7; + }); + + // 3. Attribuer les valeurs restantes aux caractéristiques Low priority + const [lowValues] = popRandomItems(remaining2, priorities.low.length); + priorities.low.forEach((charKey, index) => { + characteristics[charKey] = lowValues[index] || 7; + }); + + // S'assurer que toutes les caractéristiques sont définies + UPP_ORDER.forEach(charKey => { + if (characteristics[charKey] === undefined) { + characteristics[charKey] = 7; + } + }); + + // Construire l'UPP + const upp = UPP_ORDER.map(charKey => toHex(characteristics[charKey])).join(''); + + return { + characteristics, + upp, + category + }; +} + +// ============================================================================ +// Génération des compétences +// ============================================================================ + +/** + * Distribue les niveaux de compétence sur une liste de compétences + * + * @param {string[]} roleSkills - Liste des compétences du rôle + * @param {Object} distribution - Distribution des niveaux (level0, level1, level2, level3) + * @returns {Map} - Map des compétences avec leurs niveaux + */ +function distributeSkillLevels(roleSkills, distribution) { + const skillLevels = new Map(); + const maxLevelBySkill = new Map(); + + // 1. On commence par les compétences de niveau 3 (les plus rares) + let remainingSkills = [...roleSkills]; + + // Supprimer les doublons (spécialisations) + const uniqueSkills = []; + const seenSkills = new Set(); + for (const skill of remainingSkills) { + const baseSkill = skill.split('-')[0]; + if (!seenSkills.has(baseSkill)) { + seenSkills.add(baseSkill); + uniqueSkills.push(skill); + } + } + remainingSkills = uniqueSkills; + + // Level 3 + if (distribution.level3 > 0) { + const [level3Skills, remaining] = popRandomItems(remainingSkills, distribution.level3); + level3Skills.forEach(skill => { + skillLevels.set(skill, 3); + maxLevelBySkill.set(skill, 3); + }); + remainingSkills = remaining; + } + + // Level 2 + if (distribution.level2 > 0) { + const [level2Skills, remaining] = popRandomItems(remainingSkills, distribution.level2); + level2Skills.forEach(skill => { + const currentLevel = skillLevels.get(skill) || 0; + const newLevel = Math.max(currentLevel, 2); + skillLevels.set(skill, newLevel); + if (newLevel > (maxLevelBySkill.get(skill) || 0)) { + maxLevelBySkill.set(skill, newLevel); + } + }); + remainingSkills = remaining; + } + + // Level 1 + if (distribution.level1 > 0) { + const [level1Skills, remaining] = popRandomItems(remainingSkills, distribution.level1); + level1Skills.forEach(skill => { + const currentLevel = skillLevels.get(skill) || 0; + const newLevel = Math.max(currentLevel, 1); + skillLevels.set(skill, newLevel); + if (newLevel > (maxLevelBySkill.get(skill) || 0)) { + maxLevelBySkill.set(skill, newLevel); + } + }); + remainingSkills = remaining; + } + + // Level 0 - les compétences restantes + if (distribution.level0 > 0) { + const [level0Skills] = popRandomItems(remainingSkills, distribution.level0); + level0Skills.forEach(skill => { + const currentLevel = skillLevels.get(skill) || 0; + if (currentLevel === 0) { + skillLevels.set(skill, 0); + } + }); + } + + // Pour les compétences restantes non sélectionnées, leur donner niveau 0 + for (const skill of roleSkills) { + if (!skillLevels.has(skill)) { + skillLevels.set(skill, 0); + } + } + + return skillLevels; +} + +/** + * Génère les compétences d'un PNJ en fonction de son rôle et de son expérience + * + * @param {string} roleKey - Clé du rôle + * @param {string} experienceKey - Clé du niveau d'expérience + * @returns {Array<{name: string, level: number}>} - Liste des compétences avec niveaux + */ +export function generateSkills(roleKey, experienceKey) { + const roleSkills = getSkillsForRole(roleKey); + const experience = getExperienceLevelByKey(experienceKey); + const distribution = experience.skillDistribution; + + const skillLevels = distributeSkillLevels(roleSkills, distribution); + + // Convertir en tableau trié par niveau (descendant) puis par nom + const skills = Array.from(skillLevels.entries()) + .sort((a, b) => { + // D'abord par niveau (descendant) + if (b[1] !== a[1]) { + return b[1] - a[1]; + } + // Puis par nom (ascendant) + return a[0].localeCompare(b[0]); + }) + .map(([name, level]) => ({ name, level })); + + return skills; +} + +// ============================================================================ +// Génération du nom +// ============================================================================ + +/** + * Génère un nom aléatoire + * + * @param {string} genderKey - Clé du genre + * @returns {{firstName: string, surname: string, fullName: string}} + */ +export function generateName(genderKey) { + return pickRandomItem(NAME_CATALOGS.nonGenderedNames) + ' ' + pickRandomItem(NAME_CATALOGS.surnames); +} + +// ============================================================================ +// Génération complète du PNJ +// ============================================================================ + +/** + * Convertit un nom de compétence du format Traveller vers le format mgt2e + * @param {string} skillName - Nom de la compétence (ex: "Pilot-Spacecraft") + * @returns {string} - Nom de la compétence au format mgt2e (ex: "pilot.spacecraft") + */ +function convertSkillToMgt2eFormat(skillName) { + // Remplacer les tirets par des points + // Note: Certaines compétences ont des spécialisations comme "Pilot-Small Craft" -> "pilot.smallcraft" + const mapping = { + 'Pilot-Spacecraft': 'pilot.spacecraft', + 'Pilot-Small Craft': 'pilot.smallcraft', + 'Pilot': 'pilot', + 'Astrogation': 'astrogation', + 'Electronics-Sensors': 'electronics.sensors', + 'Electronics-Communications': 'electronics.communications', + 'Electronics-Computers': 'electronics.computers', + 'Electronics': 'electronics', + 'Gunner-Turrets': 'gunner.turrets', + 'Gunner-Screens': 'gunner.screens', + 'Gunner': 'gunner', + 'Mechanic': 'mechanic', + 'Engineer-MDrive': 'engineer.mdrive', + 'Engineer-Power': 'engineer.power', + 'Engineer-JDrive': 'engineer.jdrive', + 'Engineer-Life Support': 'engineer.lifesupport', + 'Engineer': 'engineer', + 'Steward': 'steward', + 'Carouse': 'carouse', + 'Persuade': 'persuade', + 'Broker': 'broker', + 'Admin': 'admin', + 'Computers': 'electronics.computers', + 'Language': 'language', + 'Advocate': 'advocate', + 'Leadership': 'leadership', + 'Medic': 'medic', + 'Streetwise': 'streetwise', + 'Diplomat': 'diplomat', + 'Science-Biology': 'science.biology', + 'Science-Chemistry': 'science.chemistry', + 'Science': 'science', + 'Deception': 'deception', + 'Investigate': 'investigate', + 'Gun Combat': 'guncombat', + 'GunCombat': 'guncombat', + 'Heavy Weapons': 'heavyweapons', + 'HeavyWeapons': 'heavyweapons', + 'Melee-Unarmed': 'melee.unarmed', + 'Melee-Blade': 'melee.blade', + 'Melee': 'melee', + 'Athletics-Strength': 'athletics.strength', + 'Athletics-Dexterity': 'athletics.dexterity', + 'Athletics': 'athletics', + 'Tactics': 'tactics', + 'Recon': 'recon', + 'Survival': 'survival', + 'Navigation': 'navigation', + 'Stealth': 'stealth', + 'Explosives': 'explosives', + 'Communications': 'electronics.communications', + 'Drive-Grav': 'drive.grav', + 'Drive': 'drive', + 'Vacc Suit': 'vaccsuit', + 'VaccSuit': 'vaccsuit', + 'Flyer': 'flyer', + 'Art-Acting': 'art.acting', + 'Art-Instrument': 'art.instrument', + 'Art': 'art', + 'Flyer': 'flyer', + 'Engineer-Manoeuvre Drive': 'engineer.manoeuvredrive', + 'Engineer-Manoeuvre': 'engineer.manoeuvredrive' + }; + + return mapping[skillName] || skillName.toLowerCase().replace(/-| /g, '.'); +} + +/** + * Génère un PNJ Traveller complet + * + * @param {Object} options - Options de génération + * @param {string} [options.citizenCategory='average'] - Catégorie de citoyen + * @param {string} [options.experience='regular'] - Niveau d'expérience + * @param {string} [options.role='pilot'] - Rôle + * @param {string} [options.gender='unspecified'] - Genre + * @param {string} [options.firstName] - Prénom forcé + * @param {string} [options.surname] - Nom de famille forcé + * @returns {Object} - PNJ généré + */ +export function generateTravellerNpc(options = {}) { + // Fusionner avec les options par défaut + const opts = { + ...DEFAULT_OPTIONS, + ...options + }; + + // Générer le nom + let name; + if (opts.firstName && opts.surname) { + name = { + firstName: opts.firstName, + surname: opts.surname, + fullName: `${opts.firstName} ${opts.surname}` + }; + } else { + name = { + firstName: pickRandomItem( + opts.gender === 'female' ? NAME_CATALOGS.femaleNames : + opts.gender === 'male' ? NAME_CATALOGS.maleNames : + NAME_CATALOGS.nonGenderedNames + ), + surname: pickRandomItem(NAME_CATALOGS.surnames), + fullName: '' + }; + name.fullName = `${name.firstName} ${name.surname}`; + } + + // Générer les caractéristiques + const { characteristics, upp, category } = generateCharacteristics( + opts.citizenCategory, + opts.role + ); + + // Générer les compétences + const skills = generateSkills(opts.role, opts.experience); + + // Convertir les compétences au format mgt2e pour la création de fiche + const skillsForActor = skills.map(s => ({ + name: convertSkillToMgt2eFormat(s.name), + level: s.level + })); + + // Récupérer les objets complets pour les références + const citizenCategory = getCitizenCategoryByKey(opts.citizenCategory); + const experience = getExperienceLevelByKey(opts.experience); + const role = getRoleByKey(opts.role); + const gender = getGenderByKey(opts.gender); + + // Libellés des caractéristiques pour l'affichage + const characteristicLabels = {}; + CHARACTERISTIC_LIST.forEach(char => { + characteristicLabels[char.key] = char.label; + }); + + return { + success: true, + type: 'traveller-npc', + name, + role, + citizenCategory, + experience, + gender, + characteristics, + upp, + skills, + skillsForActor, // Compétences au format mgt2e + MODULE_ID, + UPP_ORDER, + // Métadonnées pour l'affichage + display: { + roleLabel: role.label, + categoryLabel: citizenCategory.label, + experienceLabel: experience.label, + genderLabel: gender.label, + characteristicLabels + } + }; +} + +// ============================================================================ +// Création de la fiche d'acteur +// ============================================================================ + +/** + * Récupère le système de base des acteurs mgt2e + * @returns {Promise} - Système de base ou null + */ +async function getMgt2eBaseActorSystem() { + if (!mgt2eBaseActorSystemPromise) { + mgt2eBaseActorSystemPromise = (async () => { + try { + const pack = game.packs.get('mgt2e.base-actors'); + if (!pack) return null; + + const index = Array.from(await pack.getIndex({ fields: ['name', 'type'] })); + const entry = index.find((document) => document.name === 'DEFAULT TRAVELLER') + ?? index.find((document) => document.type === 'traveller') + ?? index[0]; + if (!entry?._id) return null; + + const document = await pack.getDocument(entry._id); + return document?.toObject()?.system ?? null; + } catch (error) { + console.warn(`${MODULE_ID} | Erreur lors de la récupération du système de base mgt2e:`, error); + return null; + } + })(); + } + + const system = await mgt2eBaseActorSystemPromise; + return system ? foundry.utils.deepClone(system) : null; +} + +/** + * Construit les caractéristiques au format mgt2e + * + * @param {Object} existingCharacteristics - Caractéristiques existantes (optionnel) + * @param {Object} characteristics - Caractéristiques générées + * @returns {Object} - Caractéristiques au format mgt2e + */ +export function buildMgt2eCharacteristics(existingCharacteristics = {}, characteristics) { + const result = foundry.utils.deepClone(existingCharacteristics); + + for (const [key, char] of Object.entries(CHARACTERISTIC)) { + const value = characteristics[char.key] || 7; + result[char.mgt2eKey] = foundry.utils.mergeObject(result[char.mgt2eKey] ?? {}, { + value, + current: value, + dm: calculateDm(value), + show: true, + default: false, + }); + } + + return result; +} + +/** + * Construit les compétences au format mgt2e + * + * @param {Object} existingSkills - Compétences existantes (optionnel) + * @param {Array<{name: string, level: number}>} skills - Compétences générées + * @returns {Object} - Compétences au format mgt2e + */ +export function buildMgt2eSkills(existingSkills = {}, skills, useMgt2eFormat = false) { + const result = foundry.utils.deepClone(existingSkills); + + for (const { name, level } of skills) { + // Si useMgt2eFormat est vrai, on utilise directement le nom + // Sinon, on convertit + const skillName = useMgt2eFormat ? name : convertSkillToMgt2eFormat(name); + setSkillLevel(result, skillName, level); + } + + return result; +} + +/** + * Construit la description de l'acteur + * + * @param {Object} npcData - Données du PNJ généré + * @param {string} actorName - Nom de l'acteur + * @returns {string} - Description formatée + */ +function buildActorDescription(npcData, actorName) { + const notableSkills = npcData.skills + .filter(s => s.level > 0) + .map(s => { + // Essayer de localiser la compétence + try { + return localizeSkill(s.name); + } catch (e) { + return s.name; + } + }) + .join(', '); + + return [ + `${actorName} — ${npcData.role.label}`, + `Catégorie : ${npcData.citizenCategory.label}`, + `Expérience : ${npcData.experience.label}`, + `UPP : ${npcData.upp}`, + `Genre : ${npcData.gender.label}`, + notableSkills ? `Compétences : ${notableSkills}` : '' + ].filter(line => line).join('\n'); +} + +/** + * Crée une fiche d'acteur pour un PNJ Traveller + * + * @param {Object} npcData - Données du PNJ généré + * @param {Object} options - Options de création + * @param {string} [options.name] - Nom de l'acteur + * @param {boolean} [options.openSheet=true] - Ouvrir la fiche après création + * @returns {Promise} - Acteur créé ou null + */ +export async function createTravellerNpcActor(npcData, options = {}) { + try { + const requestedName = options.name?.trim(); + const baseActorSystem = game.system?.id === 'mgt2e' ? await getMgt2eBaseActorSystem() : null; + + const actorName = requestedName || npcData.name.fullName || `PNJ — ${npcData.role.label}`; + + const actorData = { + name: actorName, + type: 'npc', + img: 'systems/mgt2e/icons/cargo/passenger-middle.svg', + system: { + settings: foundry.utils.mergeObject( + foundry.utils.deepClone(baseActorSystem?.settings ?? {}), + { + hideUntrained: true, + lockCharacteristics: true, + } + ), + sophont: foundry.utils.mergeObject( + foundry.utils.deepClone(baseActorSystem?.sophont ?? {}), + { + age: 18 + Math.floor(Math.random() * 40), // Âge entre 18 et 58 ans + homeworld: '', + profession: npcData.role.label, + } + ), + characteristics: buildMgt2eCharacteristics( + foundry.utils.deepClone(baseActorSystem?.characteristics ?? {}), + npcData.characteristics + ), + hits: foundry.utils.deepClone(baseActorSystem?.hits ?? {}), + skills: buildMgt2eSkills( + foundry.utils.deepClone(baseActorSystem?.skills ?? {}), + npcData.skillsForActor, + true // Utiliser le format mgt2e directement + ), + description: buildActorDescription(npcData, actorName), + }, + flags: { + [MODULE_ID]: { + generatedTravellerNpc: { + version: 1, + role: npcData.role.key, + citizenCategory: npcData.citizenCategory.key, + experience: npcData.experience.key, + gender: npcData.gender.key, + upp: npcData.upp, + generatedAt: new Date().toISOString() + }, + }, + }, + }; + + // S'assurer que le nom est défini + actorData.name = actorName; + actorData.system.sophont = foundry.utils.mergeObject( + actorData.system.sophont ?? {}, + { + profession: npcData.role.label, + } + ); + + // Remplacer les sauts de ligne par des
pour HTML + actorData.system.description = actorData.system.description.replace(/\n/g, '
'); + + const actor = await Actor.create(actorData, { renderSheet: false }); + + if (options.openSheet !== false) { + actor.sheet?.render(true); + } + + return actor; + } catch (error) { + console.error(`${MODULE_ID} | Erreur lors de la création de l'acteur Traveller NPC:`, error); + ui.notifications.error(`Erreur lors de la création de la fiche PNJ: ${error.message}`); + return null; + } +} + +// ============================================================================ +// Fonction principale exportée +// ============================================================================ + +/** + * Fonction principale pour générer un PNJ Traveller + * Peut créer une fiche d'acteur si demandé + * + * @param {Object} options - Options de génération + * @param {string} [options.citizenCategory] - Catégorie de citoyen + * @param {string} [options.experience] - Niveau d'expérience + * @param {string} [options.role] - Rôle + * @param {string} [options.gender] - Genre + * @param {boolean} [options.createActor] - Créer une fiche d'acteur + * @param {string} [options.actorName] - Nom de la fiche + * @param {boolean} [options.openCreatedActor] - Ouvrir la fiche créée + * @returns {Promise} - Résultat avec le PNJ généré et éventuellement l'acteur + */ +export async function generateAndCreateTravellerNpc(options = {}) { + const npcData = generateTravellerNpc(options); + + let actor = null; + if (options.createActor) { + actor = await createTravellerNpcActor(npcData, { + name: options.actorName, + openSheet: options.openCreatedActor !== false + }); + + if (actor) { + npcData.createdActor = { + id: actor.id, + name: actor.name + }; + } + } + + return npcData; +} + +// ============================================================================ +// Export des fonctions utilitaires pour les tests +// ============================================================================ + +export { + generateCharacteristics, + generateSkills, + generateName, + buildMgt2eCharacteristics, + buildMgt2eSkills, + // Ré-exporter les données + CITIZEN_CATEGORY, + CITIZEN_CATEGORY_LIST, + EXPERIENCE_LEVEL, + EXPERIENCE_LEVEL_LIST, + ROLE, + ROLE_LIST, + GENDER, + GENDER_LIST, + CHARACTERISTIC, + CHARACTERISTIC_LIST, + UPP_ORDER, + NAME_CATALOGS +}; diff --git a/styles/traveller-npc.css b/styles/traveller-npc.css new file mode 100644 index 0000000..61b5ba1 --- /dev/null +++ b/styles/traveller-npc.css @@ -0,0 +1,331 @@ +/** + * Styles pour le générateur de PNJ Traveller + */ + +/* Conteneur principal */ +.mgt2-traveller-npc-dialog { + background: rgba(0, 0, 0, 0.7); + border: 1px solid #444; + box-shadow: 0 0 20px rgba(217, 178, 76, 0.3); +} + +.mgt2-traveller-npc-dialog .window-content { + background: rgba(10, 10, 10, 0.9); + color: #d8c79a; +} + +/* Formulaire */ +.mgt2-traveller-npc-form { + padding: 15px; +} + +.mgt2-traveller-npc-form h3 { + color: #d9b24c; + border-bottom: 1px solid #c9a227; + padding-bottom: 8px; + margin-bottom: 15px; + text-shadow: none; +} + +.mgt2-traveller-npc-form h3 i { + margin-right: 8px; +} + +.mgt2-traveller-npc-form .traveller-npc-intro { + color: #a0a0a0; + margin-bottom: 20px; + line-height: 1.5; +} + +/* Champs de formulaire */ +.mgt2-traveller-npc-form .form-group { + margin-bottom: 12px; +} + +.mgt2-traveller-npc-form .form-group-row { + display: flex; + gap: 15px; + margin-bottom: 12px; +} + +.mgt2-traveller-npc-form .form-group-row .form-group { + flex: 1; + margin-bottom: 0; +} + +.mgt2-traveller-npc-form label { + display: block; + margin-bottom: 4px; + color: #d8c79a; + font-weight: bold; +} + +.mgt2-traveller-npc-form select, +.mgt2-traveller-npc-form input[type="text"] { + width: 100%; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid #444; + border-radius: 3px; + color: #ffffff; + font-size: 13px; +} + +.mgt2-traveller-npc-form select:focus, +.mgt2-traveller-npc-form input[type="text"]:focus { + outline: none; + border-color: #d9b24c; +} + +.mgt2-traveller-npc-form select option { + background: #1a1a1a; + color: #ffffff; +} + +/* Champs de nom */ +.mgt2-traveller-npc-form .name-fields { + display: flex; + gap: 10px; + align-items: flex-end; +} + +.mgt2-traveller-npc-form .name-fields.hidden { + display: none; +} + +.mgt2-traveller-npc-form .name-fields .form-group { + flex: 1; +} + +.mgt2-traveller-npc-form .name-fields .btn-small { + padding: 6px 10px; + background: rgba(217, 178, 76, 0.3); + border: 1px solid #c9a227; + color: #ffffff; + border-radius: 3px; + cursor: pointer; + transition: background 0.2s; +} + +.mgt2-traveller-npc-form .name-fields .btn-small:hover { + background: rgba(217, 178, 76, 0.5); +} + +/* Checkbox */ +.mgt2-traveller-npc-form .checkbox-group { + margin: 10px 0; +} + +.mgt2-traveller-npc-form .checkbox-group label { + display: flex; + align-items: center; + cursor: pointer; + font-weight: normal; +} + +.mgt2-traveller-npc-form .checkbox-group input[type="checkbox"] { + margin-right: 8px; + width: auto; +} + +/* Hint */ +.mgt2-traveller-npc-form .hint { + font-size: 11px; + color: #888; + margin-top: 4px; +} + +/* Pied de formulaire */ +.mgt2-traveller-npc-form .form-footer { + margin-top: 20px; + text-align: center; +} + +.mgt2-traveller-npc-form .btn-calculate { + padding: 10px 20px; + background: linear-gradient(135deg, #c9a227, #d9b24c); + border: none; + border-radius: 4px; + color: #000; + font-weight: bold; + cursor: pointer; + transition: transform 0.1s, box-shadow 0.1s; +} + +.mgt2-traveller-npc-form .btn-calculate:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(217, 178, 76, 0.4); +} + +.mgt2-traveller-npc-form .btn-calculate:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.mgt2-traveller-npc-form .btn-calculate i { + margin-right: 8px; +} + +/* Required field indicator */ +.mgt2-traveller-npc-form .required { + color: #ff6b6b; +} + +/* Resultat */ +.traveller-npc-result { + background: rgba(255, 255, 255, 0.05); + border: 1px solid #444; + border-radius: 6px; + padding: 15px; + margin: 10px 0; +} + +.traveller-npc-result .npc-header { + text-align: center; + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid #333; +} + +.traveller-npc-result .npc-header h3 { + color: #d9b24c; + margin-bottom: 8px; +} + +.traveller-npc-result .npc-header h3 i { + margin-right: 8px; +} + +.traveller-npc-result .npc-name { + font-size: 18px; + font-weight: bold; + color: #ffffff; +} + +.traveller-npc-result .npc-notice { + padding: 8px 12px; + margin-bottom: 15px; + border-radius: 4px; + font-size: 13px; +} + +.traveller-npc-result .npc-notice.success { + background: rgba(46, 204, 113, 0.2); + border: 1px solid #2ecc71; + color: #2ecc71; +} + +.traveller-npc-result .npc-notice.success i { + margin-right: 8px; +} + +.traveller-npc-result .npc-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + +.traveller-npc-result .npc-detail { + background: rgba(255, 255, 255, 0.03); + padding: 8px; + border-radius: 4px; + text-align: center; +} + +.traveller-npc-result .npc-detail-label { + font-size: 11px; + color: #888; + margin-bottom: 4px; +} + +.traveller-npc-result .npc-detail-value { + font-weight: bold; + color: #ffffff; +} + +.traveller-npc-result .npc-section { + margin-bottom: 20px; +} + +.traveller-npc-result .npc-section h4 { + color: #c9a227; + margin-bottom: 10px; + font-size: 14px; +} + +.traveller-npc-result .npc-section h4 i { + margin-right: 6px; +} + +.traveller-npc-result .npc-characteristics { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.traveller-npc-result .npc-characteristic { + background: rgba(255, 255, 255, 0.05); + padding: 6px 10px; + border-radius: 4px; + min-width: 80px; + text-align: center; +} + +.traveller-npc-result .npc-char-key { + font-size: 10px; + color: #888; + text-transform: uppercase; +} + +.traveller-npc-result .npc-char-value { + font-size: 16px; + font-weight: bold; + color: #ffffff; +} + +.traveller-npc-result .npc-char-dm { + font-size: 10px; + color: #c9a227; +} + +.traveller-npc-result .npc-skills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.traveller-npc-result .npc-skill { + background: rgba(255, 255, 255, 0.05); + padding: 4px 8px; + border-radius: 3px; + font-size: 12px; + display: flex; + align-items: center; + gap: 4px; +} + +.traveller-npc-result .npc-skill-name { + color: #ffffff; +} + +.traveller-npc-result .npc-skill-level { + color: #d9b24c; + font-weight: bold; +} + +.traveller-npc-result .npc-footer { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #333; + text-align: center; + font-size: 11px; + color: #666; +} + +/* Niveaux de compétence */ +.traveller-npc-result .skillLevelSymbol { + font-size: 12px; +} diff --git a/templates/traveller-npc-dialog.hbs b/templates/traveller-npc-dialog.hbs new file mode 100644 index 0000000..e7a55ef --- /dev/null +++ b/templates/traveller-npc-dialog.hbs @@ -0,0 +1,109 @@ +
+

Générateur de PNJ Traveller

+

+ Génère un personnage non-joueur selon les règles du générateur Traveller, + avec caractéristiques, compétences et rôle aléatoires ou personnalisés. +

+ +
+ Identité du PNJ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ Caractéristiques et Expérience + +
+
+ + +
{{description}}
+
+
+ + +
{{description}}
+
+
+
+ +
+ Création de fiche d'acteur + +
+ +
+ +
+
+ + +
+
+ +
+ +
+
+ + +
diff --git a/templates/traveller-npc-result.hbs b/templates/traveller-npc-result.hbs new file mode 100644 index 0000000..dc7ba1b --- /dev/null +++ b/templates/traveller-npc-result.hbs @@ -0,0 +1,65 @@ +
+
+

PNJ Traveller généré

+
{{name.fullName}}
+
+ + {{#if createdActor}} +
+ + Fiche d'acteur créée : {{createdActor.name}} +
+ {{/if}} + +
+
+
Rôle
+
{{display.roleLabel}}
+
+
+
Catégorie
+
{{display.categoryLabel}}
+
+
+
Expérience
+
{{display.experienceLabel}}
+
+
+
Genre
+
{{display.genderLabel}}
+
+
+ +
+

Caractéristiques (UPP: {{upp}})

+
+ {{#each UPP_ORDER}} +
+
{{lookup ../display.characteristicLabels this}}
+
{{lookup ../characteristics this}}
+
{{formatDm (lookup ../characteristics this)}}
+
+ {{/each}} +
+
+ + {{#if skills}} +
+

Compétences

+
+ {{#each skills}} + {{#if (gt level 0)}} +
+ {{name}}-{{level}} + {{skillLevelSymbol level}} +
+ {{/if}} + {{/each}} +
+
+ {{/if}} + + +