Ajout de la commande /gennpc pour générer des PNJ Traveller

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 <vibe@mistral.ai>
This commit is contained in:
2026-05-27 23:09:43 +02:00
parent c3cf8f176d
commit 4f53d903eb
8 changed files with 2239 additions and 5 deletions
+663
View File
@@ -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<string, number>} - 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<Object|null>} - 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<Actor|null>} - 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 <br> pour HTML
actorData.system.description = actorData.system.description.replace(/\n/g, '<br>');
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<Object>} - 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
};