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
+6 -3
View File
@@ -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": [
{
+309
View File
@@ -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('<i class="fas fa-spinner fa-spin"></i> 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 });
}
+715
View File
@@ -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
};
+41 -2
View File
@@ -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;
}
});
+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
};
+331
View File
@@ -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;
}
+109
View File
@@ -0,0 +1,109 @@
<form class="mgt2-traveller-npc-form">
<h3><i class="fas fa-user-astronaut"></i> Générateur de PNJ Traveller</h3>
<p class="traveller-npc-intro">
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.
</p>
<fieldset>
<legend>Identité du PNJ</legend>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="useRandomName" {{#if useRandomName}}checked{{/if}}>
Utiliser un nom aléatoire
</label>
</div>
<div class="form-group-row name-fields {{#if useRandomName}}hidden{{/if}}">
<div class="form-group">
<label for="firstName">Prénom</label>
<input id="firstName" name="firstName" type="text" value="{{firstName}}" placeholder="John">
</div>
<div class="form-group">
<label for="surname">Nom de famille</label>
<input id="surname" name="surname" type="text" value="{{surname}}" placeholder="Smith">
</div>
<div class="form-group">
<button type="button" class="btn-small" data-action="randomize-name" title="Générer un nom aléatoire">
<i class="fas fa-dice-d6"></i>
</button>
</div>
</div>
<div class="form-group-row">
<div class="form-group">
<label for="gender">Genre</label>
<select id="gender" name="gender">
{{#each genders}}
<option value="{{key}}" {{#if (eq ../gender key)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
</div>
<div class="form-group">
<label for="role">Rôle <span class="required">*</span></label>
<select id="role" name="role" required>
{{#each roles}}
<option value="{{key}}" {{#if (eq ../role key)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
</div>
</div>
</fieldset>
<fieldset>
<legend>Caractéristiques et Expérience</legend>
<div class="form-group-row">
<div class="form-group">
<label for="citizenCategory">Catégorie de citoyen</label>
<select id="citizenCategory" name="citizenCategory">
{{#each citizenCategories}}
<option value="{{key}}" {{#if (eq ../citizenCategory key)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
<div class="hint">{{description}}</div>
</div>
<div class="form-group">
<label for="experience">Niveau d'expérience</label>
<select id="experience" name="experience">
{{#each experienceLevels}}
<option value="{{key}}" {{#if (eq ../experience key)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
<div class="hint">{{description}}</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>Création de fiche d'acteur</legend>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="createActor" {{#if createActor}}checked{{/if}}>
Créer une fiche PNJ dans les Acteurs
</label>
</div>
<div class="form-group-row">
<div class="form-group">
<label for="actorName">Nom de la fiche <span class="hint">(facultatif)</span></label>
<input id="actorName" name="actorName" type="text" value="{{actorName}}" placeholder="PNJ — Pilote">
</div>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="openCreatedActor" {{#if openCreatedActor}}checked{{/if}}>
Ouvrir automatiquement la fiche créée
</label>
</div>
</fieldset>
<div class="form-footer">
<button type="button" class="btn-calculate" data-action="generate-traveller-npc">
<i class="fas fa-dice-d6"></i> Générer le PNJ Traveller
</button>
</div>
</form>
+65
View File
@@ -0,0 +1,65 @@
<div class="mgt2-npc-result traveller-npc-result">
<div class="npc-header">
<h3><i class="fas fa-user-astronaut"></i> PNJ Traveller généré</h3>
<div class="npc-name">{{name.fullName}}</div>
</div>
{{#if createdActor}}
<div class="npc-notice success">
<i class="fas fa-check-circle"></i>
Fiche d'acteur créée : {{createdActor.name}}
</div>
{{/if}}
<div class="npc-details-grid">
<div class="npc-detail">
<div class="npc-detail-label">Rôle</div>
<div class="npc-detail-value">{{display.roleLabel}}</div>
</div>
<div class="npc-detail">
<div class="npc-detail-label">Catégorie</div>
<div class="npc-detail-value">{{display.categoryLabel}}</div>
</div>
<div class="npc-detail">
<div class="npc-detail-label">Expérience</div>
<div class="npc-detail-value">{{display.experienceLabel}}</div>
</div>
<div class="npc-detail">
<div class="npc-detail-label">Genre</div>
<div class="npc-detail-value">{{display.genderLabel}}</div>
</div>
</div>
<div class="npc-section">
<h4><i class="fas fa-chart-bar"></i> Caractéristiques (UPP: {{upp}})</h4>
<div class="npc-characteristics">
{{#each UPP_ORDER}}
<div class="npc-characteristic">
<div class="npc-char-key">{{lookup ../display.characteristicLabels this}}</div>
<div class="npc-char-value">{{lookup ../characteristics this}}</div>
<div class="npc-char-dm">{{formatDm (lookup ../characteristics this)}}</div>
</div>
{{/each}}
</div>
</div>
{{#if skills}}
<div class="npc-section">
<h4><i class="fas fa-graduation-cap"></i> Compétences</h4>
<div class="npc-skills">
{{#each skills}}
{{#if (gt level 0)}}
<div class="npc-skill {{skillLevelClass level}}">
<span class="npc-skill-name">{{name}}-{{level}}</span>
<span class="npc-skill-level">{{skillLevelSymbol level}}</span>
</div>
{{/if}}
{{/each}}
</div>
</div>
{{/if}}
<div class="npc-footer">
<small>Généré par le module {{MODULE_ID}}</small>
</div>
</div>