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} 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 s'épuise. // La coque épuisée est stockée { value: 0, max: n } : réparable (Phase 1). let coqueValue = parseInt(traits.coque?.value ?? 0, 10) || 0; const coqueInit = parseInt(traits.coque?.max ?? 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'] = { value: 0, max: coqueInit }; traitsNotes.push(game.i18n.format('VERMINE.reliability_coque_lost', { max: coqueInit })); } } // 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, de fortune ou de coque. * @param {Item} item * @param {Object} opts * @param {string} opts.mode "classique" | "fortune" | "coque" * @param {string} opts.skill Compétence de réparation classique (clé de skills) * @returns {Promise} */ 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 traits = item.system?.traits ?? {}; const current = parseInt(item.system?.reliability ?? 0, 10) || 0; const damagesValue = parseInt(item.system?.damages?.value ?? 0, 10) || 0; const handicap = this.panneHandicap(item); // Trait Pratique : +1 Handicap sur toutes les réparations. const pratique = !!traits.pratique; const pratiqueHandicap = pratique ? 1 : 0; const pratiqueNote = pratique ? game.i18n.localize('VERMINE.reliability_pratique') : ''; const isCoque = mode === 'coque'; const coqueMax = isCoque ? parseInt(traits.coque?.max ?? 0, 10) || 0 : 0; // Difficulté : Fiabilité d'origine (≈ actuelle + Dommages subis) pour la // réparation classique ; Fiabilité actuelle pour la réparation de fortune ; // valeur maximale du Trait pour la réparation de coque. let difficulty = current; if (isCoque) difficulty = coqueMax; else if (mode === 'classique') difficulty = Math.min(10, current + damagesValue); 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 }); } } // Trait Bien conçu : Réussite automatique sur les Réparations classiques. const bienConcu = !!traits.bien_conçu; const autoSuccess = bienConcu && mode === 'classique'; let bienConcuNote = ''; // Trait Bien conçu (fortune) : la 1ʳᵉ réparation de fortune réussie rend // 1 point de Fiabilité (compteur traits.bien_conçu.value). const bienConcuUsed = bienConcu && mode === 'fortune' && (parseInt(traits.bien_conçu?.value ?? 0, 10) || 0) > 0; const bienConcuBonus = bienConcu && mode === 'fortune' && !bienConcuUsed; // Réparation de coque : pas de panne à réparer, Difficulté = max du Trait. const required = 1 + (isCoque ? 0 : handicap) + (isCoque ? 0 : fortuneHandicap) + pratiqueHandicap; const roll = autoSuccess ? null : new Roll(`${NoD + fortuneBonus}d10cs>=${difficulty}`); if (roll) { await roll.evaluate(); await VermineUtils.showDiceSoNice(roll); } const results = roll ? (roll.dice ?? []).flatMap((d) => (d.results ?? []).map((r) => ({ value: r.result ?? 0, isSuccess: r.success, }))) : []; const successes = roll?._total ?? required; const success = autoSuccess || successes >= required; let gained = 0; let fortuneFixed = false; let coqueRestored = false; let newReliability = null; let resultLabel = ''; if (success) { if (isCoque) { coqueRestored = true; resultLabel = game.i18n.format('VERMINE.reliability_coque_restored', { max: coqueMax }); } else if (mode === 'classique') { gained = autoSuccess ? damagesValue : successes; newReliability = Math.min(10, current + gained); resultLabel = game.i18n.format('VERMINE.reliability_gained', { gained }); if (autoSuccess) { bienConcuNote = game.i18n.localize('VERMINE.reliability_bien_concu'); } } else { fortuneFixed = true; if (bienConcuBonus) { gained = 1; newReliability = Math.min(10, current + 1); bienConcuNote = game.i18n.localize('VERMINE.reliability_bien_concu_fortune'); } resultLabel = game.i18n.localize('VERMINE.reliability_fixed'); } } else { resultLabel = game.i18n.localize('VERMINE.reliability_failed'); } const updates = {}; if (isCoque && coqueRestored) { updates['system.traits.coque'] = { value: coqueMax, max: coqueMax }; } else if (mode === 'classique' && gained > 0) { updates['system.reliability'] = newReliability; updates['system.damages.value'] = Math.max(0, damagesValue - gained); } else if (mode === 'fortune' && fortuneFixed) { updates['system.damages.value'] = 0; if (bienConcuBonus) { updates['system.reliability'] = newReliability; updates['system.traits.bien_conçu'] = { value: 1 }; } } 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 modeLabel = isCoque ? game.i18n.localize('VERMINE.reliability_repair_coque_label') : game.i18n.localize(mode === 'classique' ? 'VERMINE.reliability_repair_classic' : 'VERMINE.reliability_repair_fortune'); const content = await foundry.applications.handlebars.renderTemplate( 'systems/vermine2047/templates/reliability-repair.hbs', { item: { name: item.name, img: item.img }, modeLabel, skillLabel, difficulty, handicap: isCoque ? 0 : handicap, pratiqueHandicap, results, successes, required, resultLabel, autoSuccess, fortuneNote, bienConcuNote, pratiqueNote, }, ); 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 }); } }