/** * 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, EXPERIENCE_LEVEL, ROLE, ROLE_SKILLS, CHARACTERISTIC_PRIORITIES, GENDER, CHARACTERISTIC, CHARACTERISTIC_LIST, UPP_ORDER, toHex, calculateDm, pickRandomItem, shuffleArray, getRoleByKey, getCitizenCategoryByKey, getExperienceLevelByKey, getGenderByKey, getSkillsForRole, getCharacteristicPrioritiesForRole, validateOptions, DEFAULT_OPTIONS, NAME_CATALOGS, SKILL_LABELS_FR, CHARACTERISTIC_LABELS_FR, CITIZEN_CATEGORY_LABELS_FR, EXPERIENCE_LEVEL_LABELS_FR, ROLE_LABELS_FR, GENDER_LABELS_FR } from './data/travellerNpcGenerator.js'; import { setSkillLevel, localizeSkill } from './mgt2eSkills.js'; import { travellerNpcCache, TravellerNpcError, ERROR_CODES } from './utils/travellerNpcUtils.js'; const MODULE_ID = 'mgt2-compendium-amiral-denisov'; // ============================================================================ // Conversion des compétences // ============================================================================ /** * Mapping des compétences Traveller vers mgt2e * @type {Object} */ // Mapping des compétences Traveller vers MgT2e // IMPORTANT: Utiliser les clés internes EXACTES du système mgt2e (ex: pilot, spacecraft, smallCraft) // Basé sur le fichier fr.json du système : /home/morr/work/foundryvtt/traveller-foundryvtt/mgt2e/lang/fr.json // Format: 'Compétence-Traveller' -> 'competence_mgt2e.specialite_mgt2e' (en camelCase, sans accents) // Les clés sont en anglais, les libellés français sont gérés par la localisation const SKILL_MAPPING = { // Pilotage 'Pilot-Spacecraft': 'pilot.spacecraft', 'Pilot-Small Craft': 'pilot.smallCraft', 'Pilot': 'pilot', 'Flyer': 'flyer', // Astrogation et Navigation 'Astrogation': 'astrogation', 'Navigation': 'navigation', // Électronique 'Electronics-Sensors': 'electronics.sensors', 'Electronics-Communications': 'electronics.comms', 'Electronics-Computers': 'electronics.computers', 'Electronics': 'electronics', 'Computers': 'electronics', 'Communications': 'electronics', // Artillerie 'Gunner-Turrets': 'gunner.turret', 'Gunner-Screens': 'gunner.screen', 'Gunner': 'gunner', // Mécanique 'Mechanic': 'mechanic', // Ingénierie 'Engineer-MDrive': 'engineer.mDrive', 'Engineer-Power': 'engineer.power', 'Engineer-JDrive': 'engineer.jDrive', 'Engineer-Life Support': 'engineer.lifeSupport', 'Engineer': 'engineer', // Social et Administration 'Steward': 'steward', 'Carouse': 'carouse', 'Persuade': 'persuade', 'Broker': 'broker', 'Admin': 'admin', 'Language': 'language', 'Advocate': 'advocate', 'Leadership': 'leadership', 'Medic': 'medic', 'Diplomat': 'diplomat', // Sciences 'Science-Biology': 'science.biology', 'Science-Chemistry': 'science.chemistry', 'Science': 'science', // Combat 'Gun Combat': 'guncombat', 'Heavy Weapons': 'heavyweapons', // Mêlée 'Melee-Unarmed': 'melee.unarmed', 'Melee-Blade': 'melee.blade', 'Melee': 'melee', // Athlétisme 'Athletics-Strength': 'athletics.strength', 'Athletics-Dexterity': 'athletics.dexterity', 'Athletics': 'athletics', // Tactique et Exploration 'Tactics': 'tactics', 'Recon': 'recon', 'Survival': 'survival', 'Stealth': 'stealth', 'Explosives': 'explosives', 'Deception': 'deception', 'Investigate': 'investigate', // Conduite 'Drive-Grav': 'drive.grav', 'Drive': 'drive', // Équipement 'Vacc Suit': 'vaccsuit', // Art 'Art-Acting': 'art.performer', 'Art-Instrument': 'art.instrument', 'Art': 'art' }; /** * Convertit un nom de compétence du format Traveller vers le format mgt2e * @param {string} skillName - Nom de la compétence * @returns {string} - Nom au format mgt2e */ function convertSkillToMgt2eFormat(skillName) { // Vérifier d'abord dans le mapping explicite if (SKILL_MAPPING[skillName]) { return SKILL_MAPPING[skillName]; } // Si pas dans le mapping, essayer de deviner // MgT2e utilise des noms en minuscules (ex: pilot, electronics, guncombat) // 1. Remplacer les tirets et espaces par des points // 2. Tout mettre en minuscules return skillName.toLowerCase().replace(/-| /g, '.'); } /** * Obtient le libellé français d'une compétence Traveller * @param {string} skillName - Nom de la compétence (ex: 'Pilot-Spacecraft') * @returns {string} - Libellé français */ function getSkillLabelFr(skillName) { return SKILL_LABELS_FR[skillName] || skillName; } /** * Obtient le libellé français d'une caractéristique * @param {string} charKey - Clé de la caractéristique (ex: 'STR') * @returns {string} - Libellé français */ function getCharacteristicLabelFr(charKey) { return CHARACTERISTIC_LABELS_FR[charKey] || charKey; } // ============================================================================ // 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); // Cloner et mélanger l'array de base de la catégorie let characteristicArray = shuffleArray([...category.characteristicArray]); const characteristics = {}; // Créer un tableau de priorités avec leur ordre const priorityGroups = [ { keys: priorities.high, count: priorities.high.length }, { keys: priorities.medium, count: priorities.medium.length }, { keys: priorities.low, count: priorities.low.length } ]; // Attribuer les valeurs dans l'ordre de priorité for (const group of priorityGroups) { const values = characteristicArray.slice(0, group.count); characteristicArray = characteristicArray.slice(group.count); group.keys.forEach((charKey, i) => { characteristics[charKey] = values[i] ?? 7; }); } // Remplir les valeurs manquantes avec 7 for (const charKey of UPP_ORDER) { 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 * @returns {Array<{name: string, level: number}>} - Compétences avec niveaux */ function distributeSkillLevels(roleSkills, distribution) { // Supprimer les doublons EXACTS (même nom complet de compétence) // mais conserver les spécialisations comme Pilot-Spacecraft et Pilot-Small Craft const uniqueSkills = []; const seen = new Set(); for (const skill of roleSkills) { if (!seen.has(skill)) { seen.add(skill); uniqueSkills.push(skill); } } // Mélanger les compétences une seule fois const shuffledSkills = shuffleArray([...uniqueSkills]); // Créer un tableau de niveaux, initialisé à 0 const levels = new Array(shuffledSkills.length).fill(0); // Distribuer les niveaux de manière séquentielle let index = 0; // Level 3 (les plus rares, en premier) const level3Count = Math.min(distribution.level3, shuffledSkills.length); for (let i = 0; i < level3Count; i++) { if (index < levels.length) levels[index++] = 3; } // Level 2 const level2Count = Math.min(distribution.level2, shuffledSkills.length - level3Count); for (let i = 0; i < level2Count; i++) { if (index < levels.length) levels[index++] = 2; } // Level 1 const level1Count = Math.min(distribution.level1, shuffledSkills.length - level3Count - level2Count); for (let i = 0; i < level1Count; i++) { if (index < levels.length) levels[index++] = 1; } // Level 0 (déjà initialisé) // Créer le résultat trié par niveau (descendant) puis par nom return shuffledSkills .map((skill, i) => ({ name: skill, level: levels[i] })) .sort((a, b) => b.level - a.level || a.name.localeCompare(b.name)); } /** * 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; return distributeSkillLevels(roleSkills, distribution); } // ============================================================================ // Génération complète du PNJ // ============================================================================ /** * Génère un PNJ Traveller complet * * @typedef {Object} TravellerNpcOptions * @property {string} [citizenCategory] - Catégorie de citoyen * @property {string} [experience] - Niveau d'expérience * @property {string} [role] - Rôle * @property {string} [gender] - Genre * @property {string} [firstName] - Prénom forcé * @property {string} [surname] - Nom de famille forcé * * @typedef {Object} TravellerNpcResult * @property {boolean} success - Succès de la génération * @property {string} type - Type de résultat * @property {Object} name - Nom du PNJ * @property {Object} role - Rôle * @property {Object} citizenCategory - Catégorie * @property {Object} experience - Expérience * @property {Object} gender - Genre * @property {Object} characteristics - Caractéristiques * @property {string} upp - Code UPP * @property {Array<{name: string, level: number}>} skills - Compétences * @property {Array<{name: string, level: number}>} skillsForActor - Compétences pour mgt2e * @property {Object} display - Métadonnées pour l'affichage * * @param {TravellerNpcOptions} [options={}] * @returns {TravellerNpcResult} */ export function generateTravellerNpc(options = {}) { // Valider et fusionner avec les options par défaut const opts = validateOptions({ ...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 { const firstName = pickRandomItem( opts.gender === 'female' ? NAME_CATALOGS.femaleNames : opts.gender === 'male' ? NAME_CATALOGS.maleNames : NAME_CATALOGS.nonGenderedNames ); const surname = pickRandomItem(NAME_CATALOGS.surnames); name = { firstName, surname, fullName: `${firstName} ${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 (en français) const characteristicLabels = {}; CHARACTERISTIC_LIST.forEach(char => { characteristicLabels[char.key] = getCharacteristicLabelFr(char.key); }); // Ajouter les libellés français aux compétences pour l'affichage const skillsWithLabels = skills.map(skill => ({ ...skill, labelFr: getSkillLabelFr(skill.name) })); return { success: true, type: 'traveller-npc', name, role, citizenCategory, experience, gender, characteristics, upp, skills: skillsWithLabels, skillsForActor, MODULE_ID, UPP_ORDER, display: { roleLabel: ROLE_LABELS_FR[role.key] || role.label, categoryLabel: CITIZEN_CATEGORY_LABELS_FR[citizenCategory.key] || citizenCategory.label, experienceLabel: EXPERIENCE_LEVEL_LABELS_FR[experience.key] || experience.label, genderLabel: GENDER_LABELS_FR[gender.key] || gender.label, characteristicLabels } }; } // ============================================================================ // Récupération du système de base mgt2e // ============================================================================ /** * Récupère le système de base des acteurs mgt2e * @returns {Promise} - Système de base ou null */ async function fetchMgt2eBaseActorSystem() { try { // Vérifier que le système mgt2e est actif if (game.system?.id !== 'mgt2e') { console.warn(`${MODULE_ID} | Le système mgt2e n'est pas actif`); return null; } const pack = game.packs.get('mgt2e.base-actors'); if (!pack) { console.warn(`${MODULE_ID} | Le compendium mgt2e.base-actors n'est pas disponible`); 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) { console.warn(`${MODULE_ID} | Aucun acteur de base trouvé dans mgt2e.base-actors`); return null; } const document = await pack.getDocument(entry._id); const system = document?.toObject()?.system; if (!system) { console.warn(`${MODULE_ID} | Le système de l'acteur de base est vide`); return null; } return system; } catch (error) { console.error(`${MODULE_ID} | Erreur lors de la récupération du système de base mgt2e:`, error); throw new TravellerNpcError( 'Erreur lors de la récupération du système de base mgt2e', ERROR_CODES.BASE_ACTOR_NOT_FOUND, { error: error.message } ); } } /** * Récupère le système de base des acteurs mgt2e avec cache * @param {Object} [options={}] - Options * @param {boolean} [options.forceRefresh=false] - Forcer le rafraîchissement * @returns {Promise} */ export async function getMgt2eBaseActorSystem(options = {}) { const { forceRefresh = false } = options; const cacheKey = 'baseActorSystem'; return travellerNpcCache.getOrFetch( cacheKey, fetchMgt2eBaseActorSystem, { forceRefresh } ); } // ============================================================================ // Construction des données pour l'acteur // ============================================================================ /** * Construit les caractéristiques au format mgt2e * * @param {Object} existingCharacteristics - Caractéristiques existantes * @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 char of CHARACTERISTIC_LIST) { 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 * @param {Array<{name: string, level: number}>} skills - Compétences générées * @param {boolean} [alreadyMapped=false] - Les compétences sont déjà au format mgt2e * @returns {Object} - Compétences au format mgt2e */ export function buildMgt2eSkills(existingSkills = {}, skills, alreadyMapped = false) { const result = foundry.utils.deepClone(existingSkills); for (const { name, level } of skills) { const skillName = alreadyMapped ? 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 => { try { return localizeSkill(s.name) || s.name; } catch (e) { return s.name; } }) .join(', '); const lines = [ `${actorName} — ${npcData.role.label}`, `Catégorie : ${npcData.citizenCategory.label}`, `Expérience : ${npcData.experience.label}`, `UPP : ${npcData.upp}`, `Genre : ${npcData.gender.label}` ]; if (notableSkills) { lines.push(`Compétences : ${notableSkills}`); } return lines.join('\n'); } // ============================================================================ // Création de la fiche d'acteur // ============================================================================ /** * 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 actorName = requestedName || npcData.name.fullName || `PNJ — ${npcData.role.label}`; // Vérifier que mgt2e est actif if (game.system?.id !== 'mgt2e') { throw new TravellerNpcError( 'Le système mgt2e doit être actif pour créer des fiches PNJ Traveller', ERROR_CODES.MGT2E_NOT_ACTIVE ); } // Récupérer le système de base const baseActorSystem = await getMgt2eBaseActorSystem(); // Construire la description (avec
pour HTML) const description = buildActorDescription(npcData, actorName).replace(/\n/g, '
'); 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), 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 ), description, }, 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() }, }, }, }; const actor = await Actor.create(actorData, { renderSheet: false }); if (options.openSheet !== false) { actor.sheet?.render(true); } return actor; } catch (error) { const npcError = TravellerNpcError.from(error, ERROR_CODES.ACTOR_CREATION_FAILED); npcError.notify(); return null; } } // ============================================================================ // Fonction principale // ============================================================================ /** * Fonction principale pour générer un PNJ Traveller * Peut créer une fiche d'acteur si demandé * * @param {TravellerNpcOptions} [options={}] * @returns {Promise} */ export async function generateAndCreateTravellerNpc(options = {}) { const npcData = generateTravellerNpc(options); if (options.createActor) { const 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 types et données pour compatibilité // ============================================================================ // Ré-exporter les données pour facilitier les imports export { 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, toHex, calculateDm, pickRandomItem, shuffleArray, getRoleByKey, getCitizenCategoryByKey, getExperienceLevelByKey, getGenderByKey, getSkillsForRole, getCharacteristicPrioritiesForRole, validateOptions, DEFAULT_OPTIONS } from './data/travellerNpcGenerator.js';