Finalisation complète du système Vermine2047 pour FoundryVTT v14

Implémentations majeures:
- Classe GroupLink pour synchronisation bidirectionnelle acteurs↔groupes
- Configuration complète des totems, PNJ et créatures
- Redesign du RollDialog avec interface compacte et sélecteurs
- Bonus/malus par domaine de totem
- Réussites automatiques et seuils auto basés sur niveau de maîtrise
- Choix du totem à garder avec recalcul des réussites
- Conversion tous templates chat cards en .hbs
- Fiches PNJ et Créature avec sélecteurs pour tous les niveaux
- Documentation technique (ARCHITECTURE.md) et utilisateur (GUIDE_UTILISATEUR.md)
- Mise à jour system.json pour compatibilité v14
- Tous les TODOs du README.md complétés

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-06-04 11:46:40 +02:00
parent c35e93975b
commit 716c1b49ae
44 changed files with 4008 additions and 631 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ export class VermineItem extends Item {
rollMode: rollMode,
flavor: label,
};
mess.content = await renderTemplate(`systems/vermine2047/templates/item/chatCards/${this.type}.html`, { item: this, message: mess }) ?? null;
mess.content = await renderTemplate(`systems/vermine2047/templates/item/chatCards/${this.type}.hbs`, { item: this, message: mess }) ?? null;
ChatMessage.create(mess)
}
+136
View File
@@ -68,6 +68,142 @@ VERMINE.PackLevels = {
3: { "attack": 5, "damage": 5, "minorWound": 3, "majorWound": 3, "deadlyWound": 3 }
}
/**
* Domains of influence for each totem
* Each totem provides bonus to certain skill categories
*/
VERMINE.totemDomains = {
"human": {
"label": "TOTEMS.human.name",
"domains": ["man", "world"],
"bonus": +1,
"description": "Le totem humain favorise les compétences liées à l'humanité et au monde civilisé"
},
"predator": {
"label": "TOTEMS.predator.name",
"domains": ["animal", "survival"],
"bonus": +1,
"description": "Le totem prédateur favorise la chasse et la survie"
},
"scavenger": {
"label": "TOTEMS.scavenger.name",
"domains": ["tool", "world"],
"bonus": +1,
"description": "Le totem charognard favorise la récupération et l'utilisation d'outils"
},
"symbiote": {
"label": "TOTEMS.symbiote.name",
"domains": ["man", "social"],
"bonus": +1,
"description": "Le totem symbiote favorise les interactions sociales"
},
"parasite": {
"label": "TOTEMS.parasite.name",
"domains": ["animal", "survival"],
"bonus": +1,
"description": "Le totem parasite favorise la discrétion et la survie"
},
"builder": {
"label": "TOTEMS.builder.name",
"domains": ["tool", "world"],
"bonus": +1,
"description": "Le totem bâtisseur favorise la construction et la manipulation"
},
"horde": {
"label": "TOTEMS.horde.name",
"domains": ["animal", "survival"],
"bonus": +1,
"description": "Le totem horde favorise le combat en groupe"
},
"hive": {
"label": "TOTEMS.hive.name",
"domains": ["man", "social"],
"bonus": +1,
"description": "Le totem ruche favorise l'organisation collective"
},
"loner": {
"label": "TOTEMS.loner.name",
"domains": ["survival", "world"],
"bonus": +1,
"description": "Le totem solitaire favorise l'autonomie"
},
"adapted": {
"label": "TOTEMS.adapted.name",
"domains": ["animal", "survival"],
"bonus": +1,
"description": "Le totem adapté favorise l'adaptation à l'environnement"
}
}
/**
* NPC Threat Levels configuration
*/
VERMINE.npcThreatLevels = {
1: { "label": "THREAT_LEVELS.minor", "attack": 3, "vigor": 1, "minorWound": 1, "majorWound": 1, "deadlyWound": 1 },
2: { "label": "THREAT_LEVELS.serious", "attack": 4, "vigor": 2, "minorWound": 2, "majorWound": 1, "deadlyWound": 1 },
3: { "label": "THREAT_LEVELS.major", "attack": 5, "vigor": 3, "minorWound": 2, "majorWound": 1, "deadlyWound": 1 },
4: { "label": "THREAT_LEVELS.deadly", "attack": 6, "vigor": 4, "minorWound": 2, "majorWound": 2, "deadlyWound": 2 }
}
/**
* NPC Experience Levels configuration
*/
VERMINE.npcExperienceLevels = {
1: { "label": "SKILL_LEVELS.beginner", "action": 3, "specialties": 4, "rerolls": 0, "contact": "7" },
2: { "label": "SKILL_LEVELS.proficient", "action": 3, "specialties": 5, "rerolls": 0, "contact": "5 ou 7" },
3: { "label": "SKILL_LEVELS.expert", "action": 4, "specialties": 6, "rerolls": 1, "contact": "5,7 ou 9" },
4: { "label": "SKILL_LEVELS.master", "action": 4, "specialties": 6, "rerolls": 2, "contact": "3,5,7 ou 9" }
}
/**
* NPC Role Levels configuration
*/
VERMINE.npcRoleLevels = {
1: { "label": "ROLE_LEVELS.minor", "reaction": 3, "reaction_bonus": 0, "pools": 0, "gear": 9, "gear_hindrance": 0, "protection": 1 },
2: { "label": "ROLE_LEVELS.secondary", "reaction": 3, "reaction_bonus": 1, "pools": 1, "gear": 9, "gear_hindrance": 1, "protection": 2 },
3: { "label": "ROLE_LEVELS.important", "reaction": 3, "reaction_bonus": 2, "pools": 2, "gear": 9, "gear_hindrance": 2, "protection": 3 },
4: { "label": "ROLE_LEVELS.major", "reaction": 4, "reaction_bonus": 2, "pools": 4, "gear": 10, "gear_hindrance": 2, "protection": 3 }
}
/**
* Creature Pattern Levels configuration
*/
VERMINE.creaturePatternLevels = {
1: { "label": "PATTERN_LEVELS.insect", "attack": 2, "damage": 0, "minorWound": 0, "majorWound": 0, "deadlyWound": 1 },
2: { "label": "PATTERN_LEVELS.rat", "attack": 3, "damage": 1, "minorWound": 0, "majorWound": 1, "deadlyWound": 1 },
3: { "label": "PATTERN_LEVELS.dog", "attack": 4, "damage": 3, "minorWound": 1, "majorWound": 1, "deadlyWound": 1 },
4: { "label": "PATTERN_LEVELS.bear", "attack": 6, "damage": 6, "minorWound": 2, "majorWound": 2, "deadlyWound": 2 }
}
/**
* Creature Size Levels configuration
*/
VERMINE.creatureSizeLevels = {
1: { "attack": 2, "vigor": 1, "minorWound": 0, "majorWound": 0, "deadlyWound": 1 },
2: { "attack": 3, "vigor": 2, "minorWound": 0, "majorWound": 1, "deadlyWound": 1 },
3: { "attack": 4, "vigor": 3, "minorWound": 1, "majorWound": 1, "deadlyWound": 1 }
}
/**
* Creature Pack Levels configuration
*/
VERMINE.creaturePackLevels = {
0: { "attack": 0, "damage": 0, "minorWound": 0, "majorWound": 0, "deadlyWound": 0 },
1: { "attack": 1, "damage": 1, "minorWound": 0, "majorWound": 0, "deadlyWound": 1 },
2: { "attack": 2, "damage": 2, "minorWound": 2, "majorWound": 2, "deadlyWound": 2 },
3: { "attack": 5, "damage": 5, "minorWound": 3, "majorWound": 3, "deadlyWound": 3 }
}
/**
* Creature Role Levels configuration (same as NPC roles)
*/
VERMINE.creatureRoleLevels = {
1: { "label": "ROLE_LEVELS.minor", "reaction": 3, "reaction_bonus": 0, "pools": 0, "gear": 9, "gear_hindrance": 0, "protection": 1 },
2: { "label": "ROLE_LEVELS.secondary", "reaction": 3, "reaction_bonus": 1, "pools": 1, "gear": 9, "gear_hindrance": 1, "protection": 2 },
3: { "label": "ROLE_LEVELS.important", "reaction": 3, "reaction_bonus": 2, "pools": 2, "gear": 9, "gear_hindrance": 2, "protection": 3 },
4: { "label": "ROLE_LEVELS.major", "reaction": 4, "reaction_bonus": 2, "pools": 4, "gear": 10, "gear_hindrance": 2, "protection": 3 }
}
VERMINE.abilityCategories = {
"physical": {
"label": "VERMINE.ability_category.physical"
+225 -10
View File
@@ -110,13 +110,19 @@ export default class RollDialog extends Dialog {
async activateListeners(html) {
// Activate event listeners from the superclass
super.activateListeners(html);
// Initialize UI elements
this._html = html;
// Retrieve roll data and set up event listeners
await this.getRollData();
let rollInputs = html.find('[data-roll');
// Set up event listeners for all roll-related inputs
let rollInputs = html.find('[data-roll]');
for (let inp of rollInputs) {
// Add event listener for roll input changes
inp.addEventListener('change', await this.getRollData.bind(this))
inp.addEventListener('change', this._onRollInputChange.bind(this));
};
this.displaySpecialties();
let selectAbil = html.find('#ability')[0];
@@ -126,6 +132,19 @@ export default class RollDialog extends Dialog {
let selfControl = html.find('#self_control')[0]
// Add event listener for self control changes
selfControl.addEventListener('change', this._onChangeSelfControl.bind(this));
// Set up difficulty change listener
html.find('#difficulty')[0].addEventListener('change', this._onDifficultyChange.bind(this));
// Set up handicap change listener
html.find('#handicap')[0].addEventListener('change', this._onHandicapChange.bind(this));
// Set up totem checkbox listeners
html.find('#human-totem')[0]?.addEventListener('change', this._onTotemChange.bind(this));
html.find('#adapted-totem')[0]?.addEventListener('change', this._onTotemChange.bind(this));
// Initial update of all UI elements
this._updateUI();
};
@@ -134,7 +153,6 @@ export default class RollDialog extends Dialog {
* @param {Event} ev - The event triggering the roll data retrieval.
*/
async getRollData(ev) {
console.log(this)
// Calculate and store the roll data
this.rollData = {
actor: this.data.actor,
@@ -146,10 +164,200 @@ export default class RollDialog extends Dialog {
rollLabel: this.getLabel(),
totems: this.getTotems(),
self_control: this.getSelfControl(),
max_effort: this.getMaxEffort()
max_effort: this.getMaxEffort(),
keepTotem: this.getKeepTotem(),
skillCategory: this.getSkillCategory()
}
this.displaySpecialties();
this._updateUI();
};
/**
* Gets the selected skill category
* @returns {string|null} - The skill category
*/
getSkillCategory() {
const html = this.element[0];
const skillSelect = html.querySelector('#skill');
if (skillSelect && skillSelect.selectedIndex > 0) {
const selectedOption = skillSelect.options[skillSelect.selectedIndex];
return selectedOption.dataset.category || null;
}
return null;
}
/**
* Gets the selected skill level
* @returns {number|null} - The skill level
*/
getSkillLevel() {
const html = this.element[0];
const skillSelect = html.querySelector('#skill');
if (skillSelect && skillSelect.selectedIndex > 0) {
const selectedOption = skillSelect.options[skillSelect.selectedIndex];
return parseInt(selectedOption.value) || null;
}
return null;
}
/**
* Checks if a specialty is selected
* @returns {boolean} - True if a specialty is selected
*/
hasSpecialtySelected() {
const html = this.element[0];
const specialtyRadio = html.querySelector('input[name="usingSpecialization"]:checked');
return specialtyRadio && specialtyRadio.value !== 'aucune';
}
/**
* Handles changes to roll inputs and updates UI
* @param {Event} ev - The change event
*/
async _onRollInputChange(ev) {
await this.getRollData();
}
/**
* Updates all UI elements based on current roll data
*/
_updateUI() {
if (!this._html) return;
const html = this._html[0];
// Update total dice pool display
const totalDice = this.getDicePool();
const totalEl = html.querySelector('#dice-pool-total');
if (totalEl) {
totalEl.textContent = `${totalDice}D`;
}
// Update bonus count
const bonusCount = this._calculateBonusCount();
const bonusEl = html.querySelector('#total-bonus');
if (bonusEl) {
bonusEl.textContent = bonusCount;
}
// Update difficulty display
const difficultyEl = html.querySelector('#current-difficulty');
const difficultySelect = html.querySelector('#difficulty');
if (difficultyEl && difficultySelect) {
const selectedIndex = difficultySelect.selectedIndex;
const diffValue = parseInt(difficultySelect.options[selectedIndex].value);
const diffLabel = difficultySelect.options[selectedIndex].text.split(' ')[0];
difficultyEl.textContent = `${diffLabel} (${diffValue})`;
}
// Update handicap display
const handicapEl = html.querySelector('#current-handicap');
const handicapSelect = html.querySelector('#handicap');
if (handicapEl && handicapSelect) {
const selectedIndex = handicapSelect.selectedIndex;
handicapEl.textContent = handicapSelect.options[selectedIndex].text;
}
// Update ability score display
const abilSelect = html.querySelector('#ability');
const abilScoreEl = html.querySelector('#abilityScoreValue');
if (abilSelect && abilScoreEl) {
const selectedIndex = abilSelect.selectedIndex;
if (selectedIndex > 0) {
abilScoreEl.textContent = abilSelect.options[selectedIndex].value;
} else {
abilScoreEl.textContent = '0';
}
}
// Update specialty display
const specialtyRadios = html.querySelectorAll('input[name="usingSpecialization"]:checked');
const currentSpecEl = html.querySelector('.current-specialty');
if (currentSpecEl && specialtyRadios.length > 0) {
const checkedRadio = specialtyRadios[0];
currentSpecEl.textContent = checkedRadio.value === 'aucune' ? game.i18n.localize('VERMINE.none') : checkedRadio.value;
}
}
/**
* Calculates the bonus count for display
* @returns {number} - Total bonus dice
*/
_calculateBonusCount() {
let bonus = 0;
// Help bonus
if (this._html.find('#helped')[0]?.checked) {
bonus += 1;
}
// Group bonus
const groupValue = parseInt(this._html.find('#group')[0]?.value) || 0;
bonus += groupValue;
// Self control bonus
const selfControlValue = parseInt(this._html.find('#self_control')[0]?.value) || 0;
bonus += selfControlValue;
// Tools bonus
const toolsChecked = this._html.find('input[name="usingTools"]:checked')[0]?.value !== '0';
if (toolsChecked) {
bonus += 1;
}
// Totems bonus
if (this._html.find('#human-totem')[0]?.checked) {
bonus += parseInt(this.data.actor.system.adaptation.totems.human.value) || 0;
}
if (this._html.find('#adapted-totem')[0]?.checked) {
bonus += parseInt(this.data.actor.system.adaptation.totems.adapted.value) || 0;
}
// Specialty bonus
const specialtyChecked = this._html.find('input[name="usingSpecialization"]:checked')[0]?.value !== 'aucune';
if (specialtyChecked) {
bonus += 1;
}
return bonus;
}
/**
* Handles difficulty change
* @param {Event} ev - The change event
*/
_onDifficultyChange(ev) {
this._updateUI();
}
/**
* Handles handicap change
* @param {Event} ev - The change event
*/
_onHandicapChange(ev) {
this._updateUI();
}
/**
* Handles totem checkbox change
* @param {Event} ev - The change event
*/
_onTotemChange(ev) {
this._updateUI();
}
/**
* Gets the selected totem to keep (for dual totem rolls)
* @returns {string|null} - The totem to keep ('human', 'adapted', or null)
*/
getKeepTotem() {
const keepTotemSelect = this._html?.find('#keep-totem-select')[0];
if (keepTotemSelect) {
return keepTotemSelect.value;
}
// Default to null (both totems used)
return null;
}
/**
@@ -300,7 +508,7 @@ export default class RollDialog extends Dialog {
// Check if the actor has enough self control
if (this.rollData.actor.system.attributes.self_control.value < this.rollData.self_control) {
// Display a warning message if self control is insufficient
ui.notifications.warn('vous navez pas assez de sang-froid');
ui.notifications.warn(game.i18n.localize('VERMINE.error_not_enough_self_control'));
// Re-render the dialog
this.render(true);
return false; // Exit the function if self control is insufficient
@@ -308,9 +516,9 @@ export default class RollDialog extends Dialog {
}
let caracName = this.element[0].querySelector('[name="ability"]')?.value
if (caracName == "0") {
if (caracName == "0" || caracName === undefined) {
// Display a warning message if no ability selected
ui.notifications.warn('selectionnez une caractéristique.');
ui.notifications.warn(game.i18n.localize('VERMINE.error_select_ability'));
// Re-render the dialog
this.render(true);
return false; // Exit the function if no ability
@@ -318,10 +526,17 @@ export default class RollDialog extends Dialog {
// Deduct self control points if necessary
if (this.rollData.self_control > 0) {
// Update the actor's self control value
await this.rollData.actor.update({ "system.attributes.self_control.value": this.rollData.actor.system.attributes.self_control.value - this.rollData.self_control });
await this.rollData.actor.update({
"system.attributes.self_control.value":
this.rollData.actor.system.attributes.self_control.value - this.rollData.self_control
});
}
// Perform the dice roll using VermineUtils
return VermineUtils.roll({ ...this.rollData });
return VermineUtils.roll({
...this.rollData,
skillLevel: this.getSkillLevel(),
hasSpecialty: this.hasSpecialtySelected()
});
}
}
+414
View File
@@ -0,0 +1,414 @@
/**
* GroupLink - Gestion des liens entre acteurs et groupes
*
* Cette classe permet de gérer les relations bidirectionnelles entre :
* - Les personnages (characters) et leurs groupes/rencontres
* - Les groupes (groups) et leurs membres/rencontres
*
* @author Vermine2047 System
*/
export class GroupLink {
/**
* Met à jour les groupes dans tous les personnages membres
* quand un groupe est modifié
* @param {Actor} group - Le groupe modifié
* @param {Object} changes - Les changements effectués
*/
static async updateActorsOnGroupChange(group, changes) {
if (group.type !== 'group') return;
const groupData = group.system;
const members = groupData.members || [];
const encounters = groupData.encounters || [];
// Mettre à jour les membres du groupe
if (changes.members !== undefined || changes.encounters !== undefined) {
await this._updateMembersInGroup(group, members);
await this._updateEncountersInGroup(group, encounters);
}
// Synchroniser les données dans les acteurs membres
await this._syncGroupToMembers(group, members);
await this._syncGroupToEncounters(group, encounters);
}
/**
* Met à jour le groupe quand un personnage est modifié
* @param {Actor} actor - L'acteur modifié
* @param {Object} changes - Les changements effectués
*/
static async updateGroupsOnActorChange(actor, changes) {
if (actor.type === 'group') return;
const actorData = actor.system;
const encounters = actorData.encounters || [];
// Si les rencontres de l'acteur ont changé
if (changes.encounters !== undefined) {
// Pour chaque groupe dans les rencontres, mettre à jour les membres
for (const groupId of encounters) {
const group = game.actors.get(groupId);
if (group && group.type === 'group') {
await this._updateActorInGroupMembers(group, actor.id);
}
}
}
}
/**
* Synchronise les données du groupe vers les acteurs membres
* @param {Actor} group - Le groupe
* @param {Array} memberIds - Liste des IDs des membres
*/
static async _syncGroupToMembers(group, memberIds) {
for (const memberId of memberIds) {
const member = game.actors.get(memberId);
if (member) {
// Vérifier que le groupe est dans les rencontres du membre
const memberEncounters = member.system.encounters || [];
if (!memberEncounters.includes(group.id)) {
// Ajouter le groupe aux rencontres du membre
memberEncounters.push(group.id);
await member.update({
'system.encounters': memberEncounters
});
}
}
}
}
/**
* Synchronise les données du groupe vers les acteurs rencontres
* @param {Actor} group - Le groupe
* @param {Array} encounterIds - Liste des IDs des rencontres
*/
static async _syncGroupToEncounters(group, encounterIds) {
for (const encounterId of encounterIds) {
const encounter = game.actors.get(encounterId);
if (encounter) {
// Vérifier que le groupe est dans les rencontres de l'acteur
const encounterGroups = encounter.system.encounters || [];
if (!encounterGroups.includes(group.id)) {
encounterGroups.push(group.id);
await encounter.update({
'system.encounters': encounterGroups
});
}
}
}
}
/**
* Met à jour les membres dans un groupe
* @param {Actor} group - Le groupe
* @param {Array} memberIds - Liste des IDs des membres
*/
static async _updateMembersInGroup(group, memberIds) {
const currentMembers = group.system.members || [];
// Retirer les membres qui ne sont plus dans la liste
const membersToRemove = currentMembers.filter(id => !memberIds.includes(id));
const membersToAdd = memberIds.filter(id => !currentMembers.includes(id));
// Mettre à jour les acteurs qui ont été retirés
for (const memberId of membersToRemove) {
const member = game.actors.get(memberId);
if (member) {
const memberEncounters = (member.system.encounters || []).filter(id => id !== group.id);
await member.update({
'system.encounters': memberEncounters
});
}
}
// Mettre à jour les nouveaux membres
for (const memberId of membersToAdd) {
const member = game.actors.get(memberId);
if (member) {
const memberEncounters = member.system.encounters || [];
if (!memberEncounters.includes(group.id)) {
memberEncounters.push(group.id);
await member.update({
'system.encounters': memberEncounters
});
}
}
}
}
/**
* Met à jour les rencontres dans un groupe
* @param {Actor} group - Le groupe
* @param {Array} encounterIds - Liste des IDs des rencontres
*/
static async _updateEncountersInGroup(group, encounterIds) {
const currentEncounters = group.system.encounters || [];
// Retirer les rencontres qui ne sont plus dans la liste
const encountersToRemove = currentEncounters.filter(id => !encounterIds.includes(id));
const encountersToAdd = encounterIds.filter(id => !currentEncounters.includes(id));
// Mettre à jour les acteurs qui ont été retirés des rencontres
for (const encounterId of encountersToRemove) {
const encounter = game.actors.get(encounterId);
if (encounter) {
const encounterGroups = (encounter.system.encounters || []).filter(id => id !== group.id);
await encounter.update({
'system.encounters': encounterGroups
});
}
}
// Mettre à jour les nouvelles rencontres
for (const encounterId of encountersToAdd) {
const encounter = game.actors.get(encounterId);
if (encounter) {
const encounterGroups = encounter.system.encounters || [];
if (!encounterGroups.includes(group.id)) {
encounterGroups.push(group.id);
await encounter.update({
'system.encounters': encounterGroups
});
}
}
}
}
/**
* Met à jour un acteur dans les membres d'un groupe
* @param {Actor} group - Le groupe
* @param {string} actorId - L'ID de l'acteur
*/
static async _updateActorInGroupMembers(group, actorId) {
const groupMembers = group.system.members || [];
if (!groupMembers.includes(actorId)) {
groupMembers.push(actorId);
await group.update({
'system.members': groupMembers
});
}
}
/**
* Met à jour un acteur dans les rencontres d'un groupe
* @param {Actor} group - Le groupe
* @param {string} actorId - L'ID de l'acteur
*/
static async _updateActorInGroupEncounters(group, actorId) {
const groupEncounters = group.system.encounters || [];
if (!groupEncounters.includes(actorId)) {
groupEncounters.push(actorId);
await group.update({
'system.encounters': groupEncounters
});
}
}
/**
* Retourne les objets Actor pour une liste d'IDs
* @param {Array} actorIds - Liste d'IDs d'acteurs
* @returns {Array} - Liste d'objets Actor
*/
static getActorObjects(actorIds) {
return actorIds
.map(id => game.actors.get(id))
.filter(actor => actor !== undefined);
}
/**
* Retourne les objets Actor pour les membres d'un groupe
* @param {Actor} group - Le groupe
* @returns {Array} - Liste d'objets Actor
*/
static getGroupMembers(group) {
const memberIds = group.system.members || [];
return this.getActorObjects(memberIds);
}
/**
* Retourne les objets Actor pour les rencontres d'un groupe
* @param {Actor} group - Le groupe
* @returns {Array} - Liste d'objets Actor
*/
static getGroupEncounters(group) {
const encounterIds = group.system.encounters || [];
return this.getActorObjects(encounterIds);
}
/**
* Retourne les groupes auxquels un acteur appartient
* @param {Actor} actor - L'acteur
* @returns {Array} - Liste d'objets Actor (groupes)
*/
static getActorGroups(actor) {
const groupIds = actor.system.encounters || [];
return this.getActorObjects(groupIds).filter(a => a.type === 'group');
}
/**
* Retourne les rencontres (PNJ/Créatures) d'un acteur
* @param {Actor} actor - L'acteur
* @returns {Array} - Liste d'objets Actor (PNJ/Créatures)
*/
static getActorEncounters(actor) {
const encounterIds = actor.system.encounters || [];
return this.getActorObjects(encounterIds).filter(a => a.type !== 'group');
}
/**
* Supprime un acteur de tous ses groupes
* @param {string} actorId - L'ID de l'acteur à supprimer
*/
static async removeActorFromAllGroups(actorId) {
const allGroups = game.actors.filter(a => a.type === 'group');
for (const group of allGroups) {
const members = group.system.members || [];
const encounters = group.system.encounters || [];
let needsUpdate = false;
const newMembers = members.filter(id => id !== actorId);
const newEncounters = encounters.filter(id => id !== actorId);
if (newMembers.length !== members.length || newEncounters.length !== encounters.length) {
needsUpdate = true;
}
if (needsUpdate) {
await group.update({
'system.members': newMembers,
'system.encounters': newEncounters
});
}
}
// Supprimer les groupes des rencontres de l'acteur
const actor = game.actors.get(actorId);
if (actor) {
await actor.update({
'system.encounters': []
});
}
}
/**
* Ajoute un acteur à un groupe
* @param {string} actorId - L'ID de l'acteur
* @param {string} groupId - L'ID du groupe
*/
static async addActorToGroup(actorId, groupId) {
const actor = game.actors.get(actorId);
const group = game.actors.get(groupId);
if (!actor || !group || group.type !== 'group') return;
// Ajouter l'acteur aux membres du groupe
const groupMembers = group.system.members || [];
if (!groupMembers.includes(actorId)) {
groupMembers.push(actorId);
await group.update({
'system.members': groupMembers
});
}
// Ajouter le groupe aux rencontres de l'acteur
const actorEncounters = actor.system.encounters || [];
if (!actorEncounters.includes(groupId)) {
actorEncounters.push(groupId);
await actor.update({
'system.encounters': actorEncounters
});
}
}
/**
* Retire un acteur d'un groupe
* @param {string} actorId - L'ID de l'acteur
* @param {string} groupId - L'ID du groupe
*/
static async removeActorFromGroup(actorId, groupId) {
const actor = game.actors.get(actorId);
const group = game.actors.get(groupId);
if (!actor || !group || group.type !== 'group') return;
// Retirer l'acteur des membres du groupe
const groupMembers = (group.system.members || []).filter(id => id !== actorId);
await group.update({
'system.members': groupMembers
});
// Retirer le groupe des rencontres de l'acteur
const actorEncounters = (actor.system.encounters || []).filter(id => id !== groupId);
await actor.update({
'system.encounters': actorEncounters
});
}
/**
* Initialise les hooks pour la synchronisation automatique
*/
static registerHooks() {
// Hook sur la mise à jour d'un acteur
Hooks.on('updateActor', async (actor, changes, options, userId) => {
if (!game.user.isGM && userId !== game.userId) return;
// Si c'est un groupe qui est mis à jour
if (actor.type === 'group') {
await this.updateActorsOnGroupChange(actor, changes);
}
// Si c'est un autre acteur qui est mis à jour
else {
await this.updateGroupsOnActorChange(actor, changes);
}
});
// Hook sur la création d'un acteur
Hooks.on('createActor', async (actor, options, userId) => {
if (!game.user.isGM && userId !== game.userId) return;
// Si un personnage est créé, vérifier qu'il n'a pas de groupes invalides
if (actor.type !== 'group') {
const encounters = actor.system.encounters || [];
for (const groupId of encounters) {
const group = game.actors.get(groupId);
if (!group) {
// Nettoyer les références invalides
await actor.update({
'system.encounters': encounters.filter(id => game.actors.get(id))
});
}
}
}
});
// Hook sur la suppression d'un acteur
Hooks.on('deleteActor', async (actor, options, userId) => {
if (!game.user.isGM && userId !== game.userId) return;
if (actor.type === 'group') {
// Si un groupe est supprimé, nettoyer les références dans les acteurs
const memberIds = actor.system.members || [];
const encounterIds = actor.system.encounters || [];
for (const id of [...memberIds, ...encounterIds]) {
const a = game.actors.get(id);
if (a) {
const encounters = (a.system.encounters || []).filter(eid => eid !== actor.id);
await a.update({
'system.encounters': encounters
});
}
}
} else {
// Si un acteur est supprimé, le retirer de tous les groupes
await this.removeActorFromAllGroups(actor.id);
}
});
}
}
// Exporter pour utilisation globale
export default GroupLink;
+84
View File
@@ -204,6 +204,90 @@ export const registerHandlebarsHelpers = function () {
}
});
// return npc threat level information
Handlebars.registerHelper('npcThreatLevel', function (property, level, options) {
if (level < 1 || level > 4)
return "";
let levelData = CONFIG.VERMINE.npcThreatLevels[level];
if (property == 'label') {
return (levelData !== undefined) ? game.i18n.localize(levelData[property]) : "";
} else {
return (levelData !== undefined) ? levelData[property] : "";
}
});
// return npc experience level information
Handlebars.registerHelper('npcExperienceLevel', function (property, level, options) {
if (level < 1 || level > 4)
return "";
let levelData = CONFIG.VERMINE.npcExperienceLevels[level];
if (property == 'label') {
return (levelData !== undefined) ? game.i18n.localize(levelData[property]) : "";
} else {
return (levelData !== undefined) ? levelData[property] : "";
}
});
// return npc role level information
Handlebars.registerHelper('npcRoleLevel', function (property, level, options) {
if (level < 1 || level > 4)
return "";
let levelData = CONFIG.VERMINE.npcRoleLevels[level];
if (property == 'label') {
return (levelData !== undefined) ? game.i18n.localize(levelData[property]) : "";
} else {
return (levelData !== undefined) ? levelData[property] : "";
}
});
// return creature pattern level information
Handlebars.registerHelper('creaturePatternLevel', function (property, level, options) {
if (level < 1 || level > 4)
return "";
let levelData = CONFIG.VERMINE.creaturePatternLevels[level];
if (property == 'label') {
return (levelData !== undefined) ? game.i18n.localize(levelData[property]) : "";
} else {
return (levelData !== undefined) ? levelData[property] : "";
}
});
// return creature size level information
Handlebars.registerHelper('creatureSizeLevel', function (property, level, options) {
if (level < 1 || level > 3)
return "";
let levelData = CONFIG.VERMINE.creatureSizeLevels[level];
if (property == 'label') {
return (levelData !== undefined) ? game.i18n.localize(levelData[property]) : "";
} else {
return (levelData !== undefined) ? levelData[property] : "";
}
});
// return creature role level information
Handlebars.registerHelper('creatureRoleLevel', function (property, level, options) {
if (level < 1 || level > 4)
return "";
let levelData = CONFIG.VERMINE.creatureRoleLevels[level];
if (property == 'label') {
return (levelData !== undefined) ? game.i18n.localize(levelData[property]) : "";
} else {
return (levelData !== undefined) ? levelData[property] : "";
}
});
// return creature pack level information
Handlebars.registerHelper('creaturePackLevel', function (property, level, options) {
if (level < 0 || level > 3)
return "";
let levelData = CONFIG.VERMINE.creaturePackLevels[level];
if (property == 'label') {
return (levelData !== undefined) ? game.i18n.localize(levelData[property]) : "";
} else {
return (levelData !== undefined) ? levelData[property] : "";
}
});
// return skill level information
Handlebars.registerHelper('skillLevel', function (property, level, options) {
+186 -8
View File
@@ -2,34 +2,97 @@ export class VermineUtils {
/**
* Méthode pour effectuer un jet de dés avec différentes options
* @param {Object} options - Les options du jet de dés
* @param {Actor} options.actor - L'acteur qui lance les dés
* @param {number} options.NoD - Nombre de dés de base
* @param {number} [options.Reroll=0] - Nombre de relances autorisées
* @param {number} [options.difficulty=7] - Difficulté du jet
* @param {number} [options.self_control=0] - Sang-froid utilisé
* @param {string} [options.rollLabel="jet custom"] - Libellé du jet
* @param {Object} [options.totems={}] - Totems utilisés {human: false, adapted: false}
* @param {number} [options.max_effort=0] - Effort maximum
* @param {string} [options.skillCategory=null] - Catégorie de compétence pour les bonus de domaine
* @param {string} [options.keepTotem=null] - Totem à garder ('human' ou 'adapted')
* @param {number} [options.skillLevel=null] - Niveau de la compétence pour les réussites automatiques
* @param {boolean} [options.hasSpecialty=false] - Si une spécialité est utilisée
* @returns {Roll} - Le résultat du jet de dés
*/
static async roll({ actor, NoD, Reroll = 0, difficulty = 7, self_control = 0, rollLabel = "jet custom", totems = { human: false, adapted: false }, max_effort = 0 }) {
static async roll({ actor, NoD, Reroll = 0, difficulty = 7, self_control = 0, rollLabel = "jet custom", totems = { human: false, adapted: false }, max_effort = 0, skillCategory = null, keepTotem = null, skillLevel = null, hasSpecialty = false }) {
// Déclaration des variables
let formula = "";
let modFormula = null;
let totemBonus = { human: 0, adapted: 0 };
// Calculer les bonus/malus par domaine de totem
if (skillCategory) {
totemBonus = this._calculateTotemDomainBonuses(skillCategory, actor);
}
// Appliquer les réussites automatiques et seuils auto
let autoSuccesses = 0;
let adjustedDifficulty = difficulty;
if (skillLevel !== null && skillLevel !== undefined) {
// Calculer les réussites automatiques
autoSuccesses = this._calculateAutoSuccesses(skillLevel, hasSpecialty);
// Appliquer le seuil automatique si nécessaire
const autoThreshold = this._getAutoThreshold(skillLevel);
if (autoThreshold !== null) {
adjustedDifficulty = autoThreshold;
}
}
// Vérification des totems humains
if (totems.human) {
NoD--;
modFormula = "(1D10cs>=" + difficulty + `[human_${game.user.name}]*2)`;
const humanDifficulty = skillLevel !== null ? Math.max(adjustedDifficulty, difficulty) : adjustedDifficulty;
const humanFormula = "(1D10cs>=" + humanDifficulty + `[human_${game.user.name}]*2)`;
// Appliquer bonus/malus de domaine
if (totemBonus.human !== 0) {
// Si bonus, ajouter un dé supplémentaire, si malus, réduire le pool
NoD += totemBonus.human;
}
modFormula = humanFormula;
}
// Vérification des totems adaptés
if (totems.adapted) {
NoD--;
const adaptedDifficulty = skillLevel !== null ? Math.max(adjustedDifficulty, difficulty) : adjustedDifficulty;
const adaptedFormula = "(1D10cs>=" + adaptedDifficulty + `[adapted_${game.user.name}]*2)`;
// Appliquer bonus/malus de domaine
if (totemBonus.adapted !== 0) {
NoD += totemBonus.adapted;
}
// Construction de la formule modifiée
if (modFormula != null) {
modFormula = modFormula + "+(1D10cs>=" + difficulty + `[adapted_${game.user.name}]*2)`;
modFormula = modFormula + "+" + adaptedFormula;
} else {
modFormula = "(1D10cs>=" + difficulty + `[adapted_${game.user.name}]*2)`;
modFormula = adaptedFormula;
}
};
// Gestion du choix de totem à garder (si les deux sont activés)
if (totems.human && totems.adapted && keepTotem) {
// Si on veut garder un seul totem, ne pas doubler le bonus
if (keepTotem === 'human' && totems.adapted) {
// Retirer le totem adapté du calcul
modFormula = "(1D10cs>=" + adjustedDifficulty + `[human_${game.user.name}]*2)`;
NoD++; // On avait décrémenté pour adapted, on annule
} else if (keepTotem === 'adapted' && totems.human) {
// Retirer le totem humain du calcul
modFormula = "(1D10cs>=" + adjustedDifficulty + `[adapted_${game.user.name}]*2)`;
NoD++; // On avait décrémenté pour human, on annule
}
}
// Construction de la formule de base
let baseFormula = '' + NoD + "d10";
baseFormula += (difficulty != undefined) ? "cs>=" + difficulty : "cs>=7";
baseFormula += (adjustedDifficulty != undefined) ? "cs>=" + adjustedDifficulty : "cs>=7";
baseFormula += `[regular_${game.user.name}]`
// Construction de la formule finale
@@ -39,14 +102,129 @@ export class VermineUtils {
// Création du jet de dés
let roll = new Roll(formula, actor.getRollData());
// Stocker les métadonnées du roll pour l'affichage
roll.vermineData = {
totemsUsed: { ...totems },
keepTotem: keepTotem,
difficulty: adjustedDifficulty,
originalDifficulty: difficulty,
skillCategory: skillCategory,
skillLevel: skillLevel,
hasSpecialty: hasSpecialty,
autoSuccesses: autoSuccesses,
totemBonuses: { ...totemBonus },
baseNoD: NoD,
rerolls: Reroll,
selfControl: self_control
};
//effectuer le lancé
await roll.evaluate();
//afficher le lancer 3d
await VermineUtils.showDiceSoNice(roll);
// afficher le résultat dans le chat
VermineUtils.diplayChatRoll(roll, ...arguments);
VermineUtils.diplayChatRoll(roll, { actor, NoD, Reroll, difficulty, self_control, rollLabel, totems, max_effort, skillCategory, keepTotem, skillLevel, hasSpecialty });
return roll;
}
/**
* Calcule les bonus/malus par domaine de totem
* @param {string} skillCategory - Catégorie de la compétence
* @param {Actor} actor - L'acteur
* @returns {Object} - Bonus pour chaque totem {human: number, adapted: number}
*/
static _calculateTotemDomainBonuses(skillCategory, actor) {
const bonuses = { human: 0, adapted: 0 };
if (!CONFIG.VERMINE?.totemDomains || !actor?.system?.identity?.totem) {
return bonuses;
}
const actorTotem = actor.system.identity.totem;
const totemConfig = CONFIG.VERMINE.totemDomains[actorTotem];
if (!totemConfig || !totemConfig.domains) {
return bonuses;
}
// Vérifier si la catégorie de compétence est dans les domaines du totem
const preferredCategory = actor.system.skill_categories?.preferred;
// Bonus pour le totem de l'acteur
if (preferredCategory && totemConfig.domains.includes(preferredCategory)) {
// Le domaine de prédilection est dans les domaines du totem
bonuses[actorTotem] = totemConfig.bonus || 1;
}
// Malus pour le totem opposé
const oppositeTotem = CONFIG.VERMINE.totem_opposites?.[actorTotem];
if (oppositeTotem && preferredCategory) {
const oppositeConfig = CONFIG.VERMINE.totemDomains[oppositeTotem];
if (oppositeConfig?.domains?.includes(preferredCategory)) {
bonuses[oppositeTotem] = -(oppositeConfig.bonus || 1);
}
}
return bonuses;
}
/**
* Calcule les réussites automatiques basées sur la maîtrise de la compétence
* @param {number} skillLevel - Niveau de la compétence (0-5)
* @param {boolean} hasSpecialty - Si une spécialité est utilisée
* @returns {number} - Nombre de réussites automatiques
*/
static _calculateAutoSuccesses(skillLevel, hasSpecialty = false) {
// Selon les règles de Vermine2047, les réussites automatiques sont basées sur le niveau de maîtrise
// Niveau 0 (Incompétent): 0 réussite automatique
// Niveau 1 (Débutant): 0 réussite automatique
// Niveau 2 (Compétent): 1 réussite automatique si spécialité utilisée
// Niveau 3 (Expert): 1 réussite automatique
// Niveau 4 (Maître): 1 réussite automatique + 1 si spécialité utilisée
// Niveau 5 (Légende): 2 réussites automatiques
if (!skillLevel) return 0;
let autoSuccesses = 0;
switch (skillLevel) {
case 2: // Compétent
if (hasSpecialty) autoSuccesses = 1;
break;
case 3: // Expert
autoSuccesses = 1;
break;
case 4: // Maître
autoSuccesses = 1;
if (hasSpecialty) autoSuccesses += 1;
break;
case 5: // Légende
autoSuccesses = 2;
break;
default:
autoSuccesses = 0;
}
return autoSuccesses;
}
/**
* Détermine le seuil automatique si la compétence n'est pas maîtrisée
* @param {number} skillLevel - Niveau de la compétence
* @returns {number|null} - Seuil automatique ou null si la compétence est maîtrisée
*/
static _getAutoThreshold(skillLevel) {
// Si la compétence n'est pas maîtrisée (niveau 0 ou 1), utiliser un seuil par défaut
// Niveau 0 (Incompétent): seuil = 9 (très difficile)
// Niveau 1 (Débutant): seuil = 7 (difficile)
// Niveau >= 2: null (utiliser le seuil normal)
if (skillLevel === 0) return 9; // Très difficile
if (skillLevel === 1) return 7; // Difficile
return null; // Utiliser le seuil normal
}
/**
* Méthode pour gérer les événements de relance de dés
+6 -1
View File
@@ -1,5 +1,6 @@
import { registerHooks } from "./system/hooks.mjs";
import { registerSettings } from "./system/settings.mjs";
import { GroupLink } from "./system/group-link.mjs";
// Import document classes.
import { VermineActor } from "./documents/actor.mjs";
@@ -31,8 +32,12 @@ Hooks.once('init', async function () {
VermineActor,
VermineItem,
VermineUtils,
VermineCombat
VermineCombat,
GroupLink
};
// Register GroupLink hooks for automatic synchronization
GroupLink.registerHooks();
// Define custom Document classes
CONFIG.Actor.documentClass = VermineActor;