Files
mgt2-compendium-amiral-denisov/scripts/travellerNpcGenerator.js
T
uberwald d8c61458ea Fix: Activation des spécialités dans les fiches PNJ Traveller
- Correction de setSkillLevel pour créer les spécialités manquantes automatiquement
- Mapping corrigé: Pilot-Spacecraft -> pilot.vaisseaux_spatiaux (pluriel)
- Résout le problème où les spécialités comme 'Vaisseaux spatiaux' n'étaient pas activées

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-28 00:53:20 +02:00

738 lines
23 KiB
JavaScript

/**
* 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<string, string>}
*/
// Mapping des compétences Traveller vers MgT2e
// IMPORTANT: MgT2e utilise des noms de compétences EN MINUSCULES (ex: pilot, electronics, gunner)
// basé sur les références dans npcHelper.js et mgt2eSkills.js
// Format: 'Compétence-Traveller' -> 'competence_mgt2e' ou 'competence_mgt2e.specialite'
// Les spécialités sont en minuscules avec underscores
// Si une spécialité n'existe pas dans MgT2e, setSkillLevel appliquera le niveau à la compétence parente
const SKILL_MAPPING = {
// Pilotage - MgT2e a une compétence "pilot" (confirmé par les références)
// Corrigé : Small Craft = Petits vaisseaux (pas Aéronef léger)
// Vaisseaux spatiaux au pluriel (correspond au nom dans mgt2e)
'Pilot-Spacecraft': 'pilot.vaisseaux_spatiaux',
'Pilot-Small Craft': 'pilot.petits_vaisseaux',
'Pilot': 'pilot',
'Flyer': 'pilot.aeronef_atmospherique',
// Astrogation et Navigation (compétences séparées)
'Astrogation': 'astrogation',
'Navigation': 'navigation',
// Électronique - MgT2e a une compétence "electronics" (confirmé par npcHelper.js:34)
// Révisé : "computers" → "informatique" pour alignement avec le libellé FR
'Electronics-Sensors': 'electronics.capteurs',
'Electronics-Communications': 'electronics.communications',
'Electronics-Computers': 'electronics.informatique',
'Electronics': 'electronics',
'Computers': 'electronics',
'Communications': 'electronics',
// Artillerie - MgT2e utilise "gunner" ou "guncombat" ?
// Dans npcHelper.js:37 on voit "guncombat", donc utilisons ça
'Gunner-Turrets': 'guncombat.tourelles',
'Gunner-Screens': 'guncombat.boucliers',
'Gunner': 'guncombat',
// Mécanique
'Mechanic': 'mechanic',
// Ingénierie - MgT2e utilise probablement "engineer"
'Engineer-MDrive': 'engineer.propulsion_manoeuvre',
'Engineer-Power': 'engineer.energie',
'Engineer-JDrive': 'engineer.propulsion_saut',
'Engineer-Life Support': 'engineer.support_vie',
'Engineer': 'engineer',
// Social et Administration - tous confirmés dans npcHelper.js
'Steward': 'steward',
'Carouse': 'carouse',
'Persuade': 'persuade',
'Broker': 'broker',
'Admin': 'admin',
'Language': 'language',
'Advocate': 'advocate',
'Leadership': 'leadership',
'Medic': 'medic',
'Diplomat': 'diplomat',
// Sciences
'Science-Biology': 'science.biologie',
'Science-Chemistry': 'science.chimie',
'Science': 'science',
// Combat - "guncombat" confirmé dans npcHelper.js:37
'Gun Combat': 'guncombat',
'Heavy Weapons': 'heavyweapons',
// Mêlée - "melee" confirmé dans npcHelper.js:37
'Melee-Unarmed': 'melee.sans_arme',
'Melee-Blade': 'melee.arme_blanche',
'Melee': 'melee',
// Athlétisme - probablement "athletics"
// Révisé : "dexterite" → "dextérité" (avec accent)
'Athletics-Strength': 'athletics.force',
'Athletics-Dexterity': 'athletics.dextérité',
'Athletics': 'athletics',
// Tactique et Exploration - "tactics" et "recon" confirmés dans npcHelper.js
'Tactics': 'tactics',
'Recon': 'recon',
'Survival': 'survival',
'Stealth': 'stealth',
'Explosives': 'explosives',
'Deception': 'deception',
'Investigate': 'investigate',
// Conduite - probablement "drive"
// Révisé : "gravite" → "gravité" (avec accent)
'Drive-Grav': 'drive.gravité',
'Drive': 'drive',
// Équipement - probablement "vaccsuit" ou similaire
'Vacc Suit': 'vaccsuit',
// Art - probablement "art"
// Révisé : "jeu_d_acteur" → "jeu_acteur" (plus naturel)
'Art-Acting': 'art.jeu_acteur',
'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<Object|null>} - 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<Object|null>}
*/
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<Actor|null>} - 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 <br> pour HTML)
const description = buildActorDescription(npcData, actorName).replace(/\n/g, '<br>');
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<TravellerNpcResult>}
*/
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';