Files
vermine2047/module/system/reliability.mjs
T
uberwald 2f78d1a1ea fix(reliability): corrections suite au review
- min: 0 sur reliability (l'update à 0 était clippé à 1 par le schéma —
  l'état 'détruit' était inatteignable)
- réparation de fortune (règles p. 39) : +1D si le personnage possède la
  Compétence requise, sinon Handicap de Rareté de cette Compétence
  (rarity du skill de l'acteur) ; select toujours visible (compétence requise)
  et pré-rempli depuis needSkill/skill de l'item
- dés marqués 'dommage' uniquement pour les Dommages réellement comptés
  (Étanche/Coque absorbe → dés non marqués)
- destruction : damages.value réel conservé (difficulté de réparation
  honnête au lieu de 5 forcé)
- états de panne localisés (VERMINE.damages_state_N), aussi dans panneState()
- ReliabilityDialog invoqué via .create() (convention du codebase)
2026-08-07 13:48:13 +02:00

270 lines
10 KiB
JavaScript

import { VermineUtils } from './roll.mjs';
/**
* Gestion de la Fiabilité du matériel (regles/regles_materiel.txt).
*
* Test de Fiabilité (Dommages matériels) : selon la gravité (Mineurs 1D,
* Graves 3D, Critiques 5D), on lance les dés sans Relance ni Réserve ;
* chaque dé >= Fiabilité inflige 1 Dommage (réduit la Fiabilité de 1).
* À 0, l'équipement est détruit ou inutilisable.
*
* Réparations : jet de Précision + Compétence contre la Fiabilité d'origine
* (classique) ou de Précision + Bricolage contre la Fiabilité actuelle
* (de fortune). Chaque Réussite rend 1 point de Fiabilité (classique) ;
* la réparation de fortune rend l'objet fonctionnel sans rendre de point.
*/
export class VermineReliability {
/** Gravités et nombre de dés de Dommages (p. 37). */
static SEVERITIES = { mineurs: 1, graves: 3, critiques: 5 };
/** Handicap de réparation selon les Dommages subis (Mineure I, Grave II, Critique III). */
static #PANNE_HANDICAP = { 1: 1, 2: 1, 3: 2, 4: 2, 5: 3 };
/** Compétences de réparation classique (regles_materiel p. 39). */
static REPAIR_SKILLS = ['crafting', 'mecanical', 'technology', 'armory'];
/**
* Handicap de la panne courante (0 si intact).
* @param {Item} item
* @returns {number}
*/
static panneHandicap(item) {
const value = parseInt(item.system?.damages?.value ?? 0, 10) || 0;
return this.#PANNE_HANDICAP[value] || 0;
}
/**
* État courant dérivé des Dommages subis (tableau `damages.state`).
* @param {Item} item
* @returns {string}
*/
static panneState(item) {
const damages = item.system?.damages;
const value = parseInt(damages?.value ?? 0, 10) || 0;
if (value <= 0 || !damages?.state?.[value - 1]) return '';
return game.i18n.localize(`VERMINE.damages_state_${value}`);
}
/**
* Test de Fiabilité : jet de Dommages matériels.
* @param {Item} item
* @param {Object} opts
* @param {string} opts.severity "mineurs" | "graves" | "critiques"
* @param {boolean} opts.immersion si vrai, le Trait Étanche peut annuler les Dommages
* @returns {Promise<Object|null>} Résultat (null si impossible)
*/
static async test(item, { severity = 'graves', immersion = false } = {}) {
if (!item) return null;
const diceCount = this.SEVERITIES[severity] ?? 3;
const current = parseInt(item.system?.reliability ?? 0, 10) || 0;
const traits = item.system?.traits ?? {};
const damagesValue = parseInt(item.system?.damages?.value ?? 0, 10) || 0;
if (current <= 0) {
ui.notifications.warn(game.i18n.localize('VERMINE.reliability_already_destroyed'));
return { destroyed: true, dommages: 0 };
}
// Jet des dés de Dommages (sans Relance ni Réserve)
const roll = new Roll(`${diceCount}d10`);
await roll.evaluate();
await VermineUtils.showDiceSoNice(roll);
const results = (roll.dice ?? []).flatMap((d) => (d.results ?? []).map((r) => r.result ?? 0));
const traitsNotes = [];
// Trait Étanche : ignore les Dommages d'immersion tant que la Fiabilité
// est à son maximum (objet sans Dommage cumulé).
let dommages = results.filter((r) => r >= current).length;
if (immersion && traits.etanche && damagesValue === 0) {
dommages = 0;
traitsNotes.push(game.i18n.localize('VERMINE.reliability_etanche'));
}
// Trait Coque (n) : ignore les (n) premiers Dommages puis se perd.
let coqueValue = parseInt(traits.coque?.value ?? 0, 10) || 0;
const updates = {};
if (coqueValue > 0 && dommages > 0) {
const absorbed = Math.min(coqueValue, dommages);
dommages -= absorbed;
coqueValue -= absorbed;
if (coqueValue > 0) {
updates['system.traits.coque.value'] = coqueValue;
traitsNotes.push(game.i18n.format('VERMINE.reliability_coque_absorbed', { absorbed, remaining: coqueValue }));
} else {
updates['system.traits.coque'] = null;
traitsNotes.push(game.i18n.localize('VERMINE.reliability_coque_lost'));
}
}
// Trait Cassant : toutes les pertes de Fiabilité sont majorées d'1 point.
const cassant = !!traits.cassant;
if (cassant && dommages > 0) {
dommages += 1;
traitsNotes.push(game.i18n.localize('VERMINE.reliability_cassant'));
}
const newReliability = Math.max(0, current - dommages);
const newDamagesValue = Math.min(5, damagesValue + dommages);
const destroyed = newReliability === 0;
updates['system.reliability'] = newReliability;
updates['system.damages.value'] = newDamagesValue;
await item.update(updates);
// État final affiché sur la carte.
let state = '';
if (destroyed) {
state = game.i18n.localize('VERMINE.reliability_destroyed');
} else if (newDamagesValue > 0) {
state = game.i18n.localize(`VERMINE.damages_state_${newDamagesValue}`);
} else {
state = '';
}
// Ne marque comme Dommage que les dés réellement comptés (après
// absorption Étanche/Coque) : les dés absorbés restent affichés mais
// sans le style "damage".
const candidates = results.map((v, i) => ({ v, i })).filter((o) => o.v >= current);
const damageIdx = new Set(candidates.slice(0, Math.max(0, dommages)).map((o) => o.i));
const content = await foundry.applications.handlebars.renderTemplate(
'systems/vermine2047/templates/reliability-test.hbs',
{
item: { name: item.name, img: item.img },
severityLabel: game.i18n.localize(`VERMINE.reliability_severity_${severity}`),
diceCount,
oldReliability: current,
newReliability,
dommages,
results: results.map((v, i) => ({ value: v, isDamage: damageIdx.has(i) })),
state,
destroyed,
traitsNotes,
},
);
await ChatMessage.create({
user: game.user?._id,
speaker: this.#getSpeaker(item),
content,
});
return { dommages, newReliability, destroyed };
}
/**
* Réparation classique ou de fortune.
* @param {Item} item
* @param {Object} opts
* @param {string} opts.mode "classique" | "fortune"
* @param {string} opts.skill Compétence de réparation classique (clé de skills)
* @returns {Promise<Object|null>}
*/
static async repair(item, { mode = 'classique', skill = 'technology' } = {}) {
const actor = item?.actor;
if (!item || !actor) {
ui.notifications.warn(game.i18n.localize('VERMINE.reliability_need_actor'));
return null;
}
const current = parseInt(item.system?.reliability ?? 0, 10) || 0;
const damagesValue = parseInt(item.system?.damages?.value ?? 0, 10) || 0;
const handicap = this.panneHandicap(item);
// Difficulté : Fiabilité d'origine (≈ actuelle + Dommages subis) pour la
// réparation classique ; Fiabilité actuelle pour la réparation de fortune.
const difficulty = mode === 'classique' ? Math.min(10, current + damagesValue) : current;
const precision = parseInt(actor.system?.abilities?.precision?.value ?? 0, 10) || 0;
const skillValue = mode === 'fortune'
? parseInt(actor.system?.skills?.diy?.value ?? 0, 10) || 0
: parseInt(actor.system?.skills?.[skill]?.value ?? 0, 10) || 0;
const NoD = Math.max(0, precision + skillValue);
// Réparation de fortune (p. 39) : si le personnage possède la Compétence
// requise, +1D sur le jet ; sinon, Handicap de Rareté de cette Compétence
// (le Bonus de Bricolage s'applique dans les deux cas).
const skillRarity = parseInt(actor.system?.skills?.[skill]?.rarity ?? 0, 10) || 0;
const hasSkill = (parseInt(actor.system?.skills?.[skill]?.value ?? 0, 10) || 0) > 0;
const fortuneBonus = mode === 'fortune' && hasSkill ? 1 : 0;
const fortuneHandicap = mode === 'fortune' && !hasSkill ? skillRarity : 0;
let fortuneNote = '';
if (mode === 'fortune') {
if (hasSkill) {
fortuneNote = game.i18n.format('VERMINE.reliability_fortune_skill_bonus', { skill: game.i18n.localize(`SKILLS.${skill}.name`) });
} else if (skillRarity > 0) {
fortuneNote = game.i18n.format('VERMINE.reliability_fortune_skill_missing', { skill: game.i18n.localize(`SKILLS.${skill}.name`), rarity: skillRarity });
}
}
const roll = new Roll(`${NoD + fortuneBonus}d10cs>=${difficulty}`);
await roll.evaluate();
await VermineUtils.showDiceSoNice(roll);
const results = (roll.dice ?? []).flatMap((d) => (d.results ?? []).map((r) => ({
value: r.result ?? 0,
isSuccess: r.success,
})));
const successes = roll._total ?? 0;
const required = 1 + handicap + fortuneHandicap;
const success = successes >= required;
let gained = 0;
let fortuneFixed = false;
let resultLabel = '';
if (success) {
if (mode === 'classique') {
gained = successes;
resultLabel = game.i18n.format('VERMINE.reliability_gained', { gained });
} else {
fortuneFixed = true;
resultLabel = game.i18n.localize('VERMINE.reliability_fixed');
}
} else {
resultLabel = game.i18n.localize('VERMINE.reliability_failed');
}
const updates = {};
if (mode === 'classique' && gained > 0) {
updates['system.reliability'] = Math.min(10, current + gained);
updates['system.damages.value'] = Math.max(0, damagesValue - gained);
} else if (mode === 'fortune' && fortuneFixed) {
updates['system.damages.value'] = 0;
}
if (Object.keys(updates).length) await item.update(updates);
const skillLabel = mode === 'fortune'
? game.i18n.localize('SKILLS.diy.name')
: game.i18n.localize(`SKILLS.${skill}.name`);
const content = await foundry.applications.handlebars.renderTemplate(
'systems/vermine2047/templates/reliability-repair.hbs',
{
item: { name: item.name, img: item.img },
modeLabel: game.i18n.localize(mode === 'classique' ? 'VERMINE.reliability_repair_classic' : 'VERMINE.reliability_repair_fortune'),
skillLabel,
difficulty,
handicap,
results,
successes,
required,
resultLabel,
fortuneNote,
},
);
await ChatMessage.create({
user: game.user?._id,
speaker: this.#getSpeaker(item),
content,
});
return { success, gained, successes, required };
}
static #getSpeaker(item) {
return ChatMessage.getSpeaker({ actor: item?.actor ?? null });
}
}