/** * 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 };