feat: portée armes, récupération 3T, socket loksyu, import batch

Portée des armes
- Suppression des malus de portée (mediane/longue/extreme)
- Sélecteur filtré : seules les portées <= portée max de l'arme

Récupération (3 Trésors)
- Nouveau bouton dans l'onglet Trésors
- Jet basé sur l'aspect du Gardien Céleste
- Dé pair : +1 Hei Yang, +1 San
- Dé impair : +1 Hei Yin, +1 Zing
- Fenêtre de bonus dice pour les pouvoirs (Acupuncture...)
- Ignore les malus de palier San/Zing

Socket Loksyu/TinJi
- Les joueurs peuvent mettre à jour Loksyu/TinJi via socket
- Le MJ proxy les écritures (game.settings world = GM only)
- Opérations atomiques pour éviter les races conditions (roll simultanés)

Import batch
- 4 nouveaux migrators item (weapon/armor/sanhei/ingredient)
- magicOrder dans la migration character
- Confirmation en 2 clics avant l'import
- Détection des doublons avec le monde
- Progression en direct pendant l'import
- Dédoublonnage non-silencieux + flag _duplicate calculé en direct

UI
- Noms d'items en Signika (plus lisible qu'Averia)
- ASCII art au lancement dans la console
- Clés i18n manquantes ajoutées (BonusMalus, EnablePrompt, Material, Roll, ThrowType)

Fix divers
- Calcul puissance sort : utilise l'élément de la spécialité, pas celui de l'école
- preLocalizeConfig préserve labelelementkey pour la résolution d'aspect
- combat.js: guard undefined ids dans rollInitiative
This commit is contained in:
2026-06-28 19:18:03 +02:00
parent 9c4f5948fd
commit 6b2ead8fbb
10 changed files with 497 additions and 38 deletions
+100
View File
@@ -2780,6 +2780,82 @@ section.npc .cde-neon-tabs .item.active {
color: #4a9eff; color: #4a9eff;
text-shadow: 0 0 8px rgba(74, 158, 255, 0.5); text-shadow: 0 0 8px rgba(74, 158, 255, 0.5);
} }
/* Recovery roll chat message */
.cde-chat-recovery {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 12px;
}
.cde-chat-recovery-header {
display: flex;
align-items: center;
gap: 8px;
}
.cde-chat-recovery-icon {
width: 28px;
height: 28px;
object-fit: contain;
}
.cde-chat-recovery-title {
font-family: "Averia", sans-serif;
font-size: 14px;
font-weight: 700;
color: #e2e8f4;
}
.cde-chat-recovery-dice {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.cde-chat-recovery-dice-label {
font-weight: 700;
color: #00d4d4;
}
.cde-chat-recovery-dice-results {
font-family: monospace;
font-size: 13px;
color: #e2e8f4;
}
.cde-chat-recovery-results {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px;
border-radius: 4px;
font-size: 12px;
}
.cde-chat-recovery-even {
display: flex;
align-items: center;
gap: 6px;
color: #4a9eff;
}
.cde-chat-recovery-odd {
display: flex;
align-items: center;
gap: 6px;
color: #cc44ff;
}
.cde-chat-recovery-count {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.cde-chat-recovery-even .cde-chat-recovery-count {
background: #4a9eff;
}
.cde-chat-recovery-odd .cde-chat-recovery-count {
background: #cc44ff;
}
.cde-tabs { .cde-tabs {
margin-top: 12px; margin-top: 12px;
border-bottom: 1px solid #1a2436; border-bottom: 1px solid #1a2436;
@@ -2864,6 +2940,30 @@ strong.ellipsis {
#interface .chat-message .message-header .message-sender { #interface .chat-message .message-header .message-sender {
width: 100px; width: 100px;
} }
.cde-recovery-bar {
margin-bottom: 10px;
}
.cde-recovery-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
border: 1px solid #4a9eff;
border-radius: 6px;
background: rgba(74, 158, 255, 0.15);
color: #4a9eff;
font-family: "Averia", sans-serif;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, filter 0.15s;
}
.cde-recovery-btn:hover {
background: rgba(74, 158, 255, 0.3);
filter: brightness(1.1);
}
.cde-flex-center-text { .cde-flex-center-text {
display: flex; display: flex;
justify-content: center; justify-content: center;
+120
View File
@@ -2805,6 +2805,95 @@ section.npc .cde-neon-tabs .item.active { color: @cde-supernatural; borde
text-shadow: 0 0 8px fade(@cde-spell, 50%); text-shadow: 0 0 8px fade(@cde-spell, 50%);
} }
/* Recovery roll chat message */
.cde-chat-recovery {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 12px;
}
.cde-chat-recovery-header {
display: flex;
align-items: center;
gap: 8px;
}
.cde-chat-recovery-icon {
width: 28px;
height: 28px;
object-fit: contain;
}
.cde-chat-recovery-title {
font-family: "Averia", sans-serif;
font-size: 14px;
font-weight: 700;
color: @cde-text;
}
.cde-chat-recovery-dice {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.cde-chat-recovery-dice-label {
font-weight: 700;
color: @cde-item;
}
.cde-chat-recovery-dice-results {
font-family: monospace;
font-size: 13px;
color: @cde-text;
}
.cde-chat-recovery-results {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px;
border-radius: 4px;
font-size: 12px;
}
.cde-chat-recovery-even {
display: flex;
align-items: center;
gap: 6px;
color: @cde-spell;
}
.cde-chat-recovery-odd {
display: flex;
align-items: center;
gap: 6px;
color: @cde-supernatural;
}
.cde-chat-recovery-count {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.cde-chat-recovery-even .cde-chat-recovery-count {
background: @cde-spell;
}
.cde-chat-recovery-odd .cde-chat-recovery-count {
background: @cde-supernatural;
}
// Legacy tabs (actor sheets still use cde-tabs) // Legacy tabs (actor sheets still use cde-tabs)
.cde-tabs { .cde-tabs {
margin-top: @cde-gap; margin-top: @cde-gap;
@@ -2910,6 +2999,37 @@ strong.ellipsis {
width: 100px; width: 100px;
} }
// ============================================================
// Recovery button
// ============================================================
.cde-recovery-bar {
margin-bottom: 10px;
}
.cde-recovery-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
border: 1px solid @cde-spell;
border-radius: 6px;
background: fadeout(@cde-spell, 85%);
color: @cde-spell;
font-family: "Averia", sans-serif;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, filter 0.15s;
&:hover {
background: fadeout(@cde-spell, 70%);
filter: brightness(1.1);
}
}
// ============================================================ // ============================================================
// Three Treasures partial layout // Three Treasures partial layout
// ============================================================ // ============================================================
+114 -19
View File
@@ -804,15 +804,6 @@ function preLocalizeConfig() {
// src/config/runtime.js // src/config/runtime.js
function configureRuntime() { function configureRuntime() {
CONFIG.Actor.compendiumBanner = "/systems/fvtt-chroniques-de-l-etrange/images/banners/actor-banner.webp";
CONFIG.Adventure.compendiumBanner = "/systems/fvtt-chroniques-de-l-etrange/images/banners/adventure-banner.webp";
CONFIG.Cards.compendiumBanner = "ui/banners/cards-banner.webp";
CONFIG.Item.compendiumBanner = "/systems/fvtt-chroniques-de-l-etrange/images/banners/item-banner.webp";
CONFIG.JournalEntry.compendiumBanner = "/systems/fvtt-chroniques-de-l-etrange/images/banners/journalentry-banner.webp";
CONFIG.Macro.compendiumBanner = "ui/banners/macro-banner.webp";
CONFIG.Playlist.compendiumBanner = "ui/banners/playlist-banner.webp";
CONFIG.RollTable.compendiumBanner = "ui/banners/rolltable-banner.webp";
CONFIG.Scene.compendiumBanner = "/systems/fvtt-chroniques-de-l-etrange/images/banners/scene-banner.webp";
} }
// src/data/actors/character.js // src/data/actors/character.js
@@ -1682,12 +1673,13 @@ var LABELELEMENT_TO_ASPECT = {
"CDE.Fire": "fire", "CDE.Fire": "fire",
"CDE.Wood": "wood" "CDE.Wood": "wood"
}; };
var RANGE_MALUS = { var RANGE_ORDER = ["contact", "courte", "mediane", "longue", "extreme"];
contact: 0, var RANGE_LABELS = {
courte: 0, contact: "CDE.RangeContact",
mediane: -1, courte: "CDE.RangeCourte",
longue: -2, mediane: "CDE.RangeMediane",
extreme: -3 longue: "CDE.RangeLongue",
extreme: "CDE.RangeExtreme"
}; };
var WEAPON_TYPE_SKILL = { var WEAPON_TYPE_SKILL = {
melee: "kungfu", melee: "kungfu",
@@ -1787,6 +1779,13 @@ async function showMagicPrompt(params) {
}); });
} }
async function showWeaponPrompt(params) { async function showWeaponPrompt(params) {
const maxRange = params.effectiverange ?? "contact";
const maxIdx = RANGE_ORDER.indexOf(maxRange);
const validRanges = RANGE_ORDER.slice(0, maxIdx + 1).map((r) => ({
value: r,
label: RANGE_LABELS[r],
selected: r === maxRange
}));
return showRollPrompt({ return showRollPrompt({
title: params.title, title: params.title,
template: WEAPON_PROMPT_TEMPLATE, template: WEAPON_PROMPT_TEMPLATE,
@@ -1800,6 +1799,7 @@ async function showWeaponPrompt(params) {
weaponskill: params.weaponskill ?? "kungfu", weaponskill: params.weaponskill ?? "kungfu",
aspect: Number(params.aspect ?? 0), aspect: Number(params.aspect ?? 0),
effectiverange: params.effectiverange ?? "contact", effectiverange: params.effectiverange ?? "contact",
validRanges,
bonusmalus: params.bonusmalus ?? 0, bonusmalus: params.bonusmalus ?? 0,
woundmalus: params.woundmalus ?? 0, woundmalus: params.woundmalus ?? 0,
bonusauspiciousdice: params.bonusauspiciousdice ?? 0, bonusauspiciousdice: params.bonusauspiciousdice ?? 0,
@@ -1956,14 +1956,13 @@ async function rollForActor(actor, rollKey) {
const wpSkillDice = sys.skills?.[wpChosenSkill]?.value ?? 0; const wpSkillDice = sys.skills?.[wpChosenSkill]?.value ?? 0;
const wpAspFinal = Number(wParams.aspect ?? wpAspectIdx); const wpAspFinal = Number(wParams.aspect ?? wpAspectIdx);
const wpAspectDice = sys.aspect[ASPECT_NAMES[wpAspFinal]]?.value ?? 0; const wpAspectDice = sys.aspect[ASPECT_NAMES[wpAspFinal]]?.value ?? 0;
const wpRangeMalus = RANGE_MALUS[wParams.effectiverange ?? "contact"] ?? 0;
const wpBonusMalus = Number(wParams.bonusmalus ?? 0); const wpBonusMalus = Number(wParams.bonusmalus ?? 0);
const wpWoundMalus = Number(wParams.woundmalus ?? 0); const wpWoundMalus = Number(wParams.woundmalus ?? 0);
const wpBonusAusp = Number(wParams.bonusauspiciousdice ?? 0); const wpBonusAusp = Number(wParams.bonusauspiciousdice ?? 0);
const wpThrowMode = Number(wParams.typeofthrow ?? 0); const wpThrowMode = Number(wParams.typeofthrow ?? 0);
const wpDamageBase = wpItem.system.damageBase ?? 0; const wpDamageBase = wpItem.system.damageBase ?? 0;
const wpSpecialtyBonus = wpItem.system.hasSpeciality ? 1 : 0; const wpSpecialtyBonus = wpItem.system.hasSpeciality ? 1 : 0;
const wpTotalDice = wpSkillDice + wpAspectDice + wpRangeMalus + wpBonusMalus - wpWoundMalus + wpSpecialtyBonus; const wpTotalDice = wpSkillDice + wpAspectDice + wpBonusMalus - wpWoundMalus + wpSpecialtyBonus;
if (wpTotalDice <= 0) { if (wpTotalDice <= 0) {
ui.notifications.warn(game.i18n.localize("CDE.Error0")); ui.notifications.warn(game.i18n.localize("CDE.Error0"));
return; return;
@@ -1975,7 +1974,6 @@ async function rollForActor(actor, rollKey) {
const wpResults = computeWuXingResults(wpFaces, wpAspectName, wpBonusAusp); const wpResults = computeWuXingResults(wpFaces, wpAspectName, wpBonusAusp);
if (!wpResults) return; if (!wpResults) return;
const wpModParts = []; const wpModParts = [];
if (wpRangeMalus !== 0) wpModParts.push(`${wpRangeMalus} ${game.i18n.localize("CDE.RangePenalty")}`);
if (wpBonusMalus !== 0) wpModParts.push(`${wpBonusMalus > 0 ? "+" : ""}${wpBonusMalus} ${game.i18n.localize("CDE.BonusMalus")}`); if (wpBonusMalus !== 0) wpModParts.push(`${wpBonusMalus > 0 ? "+" : ""}${wpBonusMalus} ${game.i18n.localize("CDE.BonusMalus")}`);
if (wpWoundMalus !== 0) wpModParts.push(`-${wpWoundMalus} ${game.i18n.localize("CDE.WoundMalus")}`); if (wpWoundMalus !== 0) wpModParts.push(`-${wpWoundMalus} ${game.i18n.localize("CDE.WoundMalus")}`);
if (wpBonusAusp !== 0) wpModParts.push(`+${wpBonusAusp} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`); if (wpBonusAusp !== 0) wpModParts.push(`+${wpBonusAusp} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`);
@@ -2277,7 +2275,8 @@ var CDECharacterSheet = class _CDECharacterSheet extends CDEBaseActorSheet {
classes: ["character"], classes: ["character"],
actions: { actions: {
moveMagicUp: _CDECharacterSheet.#onMoveMagicUp, moveMagicUp: _CDECharacterSheet.#onMoveMagicUp,
moveMagicDown: _CDECharacterSheet.#onMoveMagicDown moveMagicDown: _CDECharacterSheet.#onMoveMagicDown,
recovery: _CDECharacterSheet.#onRecovery
} }
}; };
static PARTS = { static PARTS = {
@@ -2447,6 +2446,102 @@ var CDECharacterSheet = class _CDECharacterSheet extends CDEBaseActorSheet {
[order[idx], order[idx + 1]] = [order[idx + 1], order[idx]]; [order[idx], order[idx + 1]] = [order[idx + 1], order[idx]];
await this.document.update({ "system.magicOrder": order }); await this.document.update({ "system.magicOrder": order });
} }
static async #onRecovery(event, target) {
const actor = this.document;
const sys = actor.system;
const guardian = Number(sys.guardian ?? 0);
if (guardian < 1 || guardian > 5) {
ui.notifications.warn(game.i18n.localize("CDE.RecoveryNoGuardian"));
return;
}
const aspectName = ASPECT_NAMES[guardian - 1];
const aspectLabel = game.i18n.localize(ASPECT_LABELS[aspectName]);
const aspectValue = sys.aspect?.[aspectName]?.value ?? 0;
if (aspectValue <= 0) {
ui.notifications.warn(game.i18n.localize("CDE.RecoveryNoAspect"));
return;
}
const RECOVERY_PROMPT = `systems/${SYSTEM_ID}/templates/form/cde-recovery-prompt.html`;
const content = await foundry.applications.handlebars.renderTemplate(RECOVERY_PROMPT, {
basedice: aspectValue
});
const params = await foundry.applications.api.DialogV2.prompt({
window: { title: game.i18n.localize("CDE.Recovery") },
content,
rejectClose: false,
ok: {
label: game.i18n.localize("CDE.Validate"),
callback: (event2, button, dialog) => {
const root = dialog.element ?? dialog;
return { bonusdice: Number(root.querySelector("[name=bonusdice]")?.value ?? 0) };
}
}
});
if (params === void 0) return;
const totalDice = aspectValue + Math.max(0, Number(params.bonusdice ?? 0));
const roll = new Roll(`${totalDice}d10`);
await roll.evaluate();
const results = roll.dice[0].results.map((r) => r.result);
const evenCount = results.filter((r) => r % 2 === 0).length;
const oddCount = results.filter((r) => r % 2 !== 0).length;
const cap = (max) => max > 0 ? max : Infinity;
const heiYang = Math.min(
(sys.threetreasures?.heiyang?.value ?? 0) + evenCount,
cap(sys.threetreasures?.heiyang?.max)
);
const heiYin = Math.min(
(sys.threetreasures?.heiyin?.value ?? 0) + oddCount,
cap(sys.threetreasures?.heiyin?.max)
);
const san = Math.min(
(sys.threetreasures?.dicelevel?.level0d?.san?.value ?? 0) + evenCount,
cap(sys.threetreasures?.dicelevel?.level0d?.san?.max)
);
const zing = Math.min(
(sys.threetreasures?.dicelevel?.level0d?.zing?.value ?? 0) + oddCount,
cap(sys.threetreasures?.dicelevel?.level0d?.zing?.max)
);
await actor.update({
"system.threetreasures.heiyang.value": heiYang,
"system.threetreasures.heiyin.value": heiYin,
"system.threetreasures.dicelevel.level0d.san.value": san,
"system.threetreasures.dicelevel.level0d.zing.value": zing
});
const rollLabel = `${game.i18n.localize("CDE.Recovery")} \u2014 ${aspectLabel}`;
const diceStr = results.join(" \xB7 ");
const typeOfThrow = Number(sys.prefs?.typeofthrow?.choice ?? 0);
const ROLL_MODES2 = ["roll", "gmroll", "blindroll", "selfroll"];
const rollMode = ROLL_MODES2[typeOfThrow] ?? "roll";
await ChatMessage.create({
user: game.user.id,
speaker: ChatMessage.getSpeaker({ actor }),
content: `
<div class="cde-chat-recovery">
<div class="cde-chat-recovery-header">
<img src="${ASPECT_ICONS[aspectName]}" class="cde-chat-recovery-icon" alt="${aspectLabel}"/>
<span class="cde-chat-recovery-title">${rollLabel}</span>
</div>
<div class="cde-chat-recovery-dice">
<span class="cde-chat-recovery-dice-label">${totalDice}d10</span>
<span class="cde-chat-recovery-dice-results">${diceStr}</span>
</div>
<div class="cde-chat-recovery-results">
<div class="cde-chat-recovery-even">
<span class="cde-chat-recovery-count">${evenCount}</span>
<span><strong>${game.i18n.localize("CDE.RecoveryEvenLabel")}</strong></span>
<span>\u2192 +${evenCount} Hei Yang, +${evenCount} San</span>
</div>
<div class="cde-chat-recovery-odd">
<span class="cde-chat-recovery-count">${oddCount}</span>
<span><strong>${game.i18n.localize("CDE.RecoveryOddLabel")}</strong></span>
<span>\u2192 +${oddCount} Hei Yin, +${oddCount} Zing</span>
</div>
</div>
</div>`,
rolls: [roll],
rollMode
});
}
#bindComponentRandomize() { #bindComponentRandomize() {
const btn = this.element?.querySelector("[data-action='randomize-component']"); const btn = this.element?.querySelector("[data-action='randomize-component']");
if (!btn) return; if (!btn) return;
+3 -3
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -285,6 +285,12 @@
"CDE.RealizationTimeAccelerated": "Temps de Réalisation (Accéléré)", "CDE.RealizationTimeAccelerated": "Temps de Réalisation (Accéléré)",
"CDE.RealizationTimeRitual": "Temps de Réalisation (Rituel)", "CDE.RealizationTimeRitual": "Temps de Réalisation (Rituel)",
"CDE.Reference": "Référence", "CDE.Reference": "Référence",
"CDE.Recovery": "Récupération",
"CDE.RecoveryBaseDice": "Dés de base",
"CDE.RecoveryEvenLabel": "Pairs",
"CDE.RecoveryNoAspect": "Votre aspect est à 0, la Récupération est impossible.",
"CDE.RecoveryNoGuardian": "Vous devez choisir un Gardien Céleste pour utiliser la Récupération.",
"CDE.RecoveryOddLabel": "Impairs",
"CDE.Renowned": "Renommé", "CDE.Renowned": "Renommé",
"CDE.ResourceMax": "Max", "CDE.ResourceMax": "Max",
"CDE.ResourceMin": "Min", "CDE.ResourceMin": "Min",
+20 -10
View File
@@ -44,13 +44,16 @@ const LABELELEMENT_TO_ASPECT = {
"CDE.Wood": "wood", "CDE.Wood": "wood",
} }
/** Maps weapon range string → dice malus applied to the attack pool */ /** Range order from closest to furthest. Weapons can be used at any
const RANGE_MALUS = { * range ≤ the weapon's listed range, with no dice malus. */
contact: 0, const RANGE_ORDER = ["contact", "courte", "mediane", "longue", "extreme"]
courte: 0,
mediane: -1, const RANGE_LABELS = {
longue: -2, contact: "CDE.RangeContact",
extreme: -3, courte: "CDE.RangeCourte",
mediane: "CDE.RangeMediane",
longue: "CDE.RangeLongue",
extreme: "CDE.RangeExtreme",
} }
/** Maps weapon type string → default skill key */ /** Maps weapon type string → default skill key */
@@ -181,6 +184,14 @@ async function showMagicPrompt(params) {
* Open the weapon attack roll prompt and return user-confirmed parameters. * Open the weapon attack roll prompt and return user-confirmed parameters.
*/ */
async function showWeaponPrompt(params) { async function showWeaponPrompt(params) {
const maxRange = params.effectiverange ?? "contact"
const maxIdx = RANGE_ORDER.indexOf(maxRange)
const validRanges = RANGE_ORDER.slice(0, maxIdx + 1).map(r => ({
value: r,
label: RANGE_LABELS[r],
selected: r === maxRange,
}))
return showRollPrompt({ return showRollPrompt({
title: params.title, title: params.title,
template: WEAPON_PROMPT_TEMPLATE, template: WEAPON_PROMPT_TEMPLATE,
@@ -194,6 +205,7 @@ async function showWeaponPrompt(params) {
weaponskill: params.weaponskill ?? "kungfu", weaponskill: params.weaponskill ?? "kungfu",
aspect: Number(params.aspect ?? 0), aspect: Number(params.aspect ?? 0),
effectiverange: params.effectiverange ?? "contact", effectiverange: params.effectiverange ?? "contact",
validRanges,
bonusmalus: params.bonusmalus ?? 0, bonusmalus: params.bonusmalus ?? 0,
woundmalus: params.woundmalus ?? 0, woundmalus: params.woundmalus ?? 0,
bonusauspiciousdice: params.bonusauspiciousdice ?? 0, bonusauspiciousdice: params.bonusauspiciousdice ?? 0,
@@ -366,7 +378,6 @@ export async function rollForActor(actor, rollKey) {
const wpSkillDice = sys.skills?.[wpChosenSkill]?.value ?? 0 const wpSkillDice = sys.skills?.[wpChosenSkill]?.value ?? 0
const wpAspFinal = Number(wParams.aspect ?? wpAspectIdx) const wpAspFinal = Number(wParams.aspect ?? wpAspectIdx)
const wpAspectDice = sys.aspect[ASPECT_NAMES[wpAspFinal]]?.value ?? 0 const wpAspectDice = sys.aspect[ASPECT_NAMES[wpAspFinal]]?.value ?? 0
const wpRangeMalus = RANGE_MALUS[wParams.effectiverange ?? "contact"] ?? 0
const wpBonusMalus = Number(wParams.bonusmalus ?? 0) const wpBonusMalus = Number(wParams.bonusmalus ?? 0)
const wpWoundMalus = Number(wParams.woundmalus ?? 0) const wpWoundMalus = Number(wParams.woundmalus ?? 0)
const wpBonusAusp = Number(wParams.bonusauspiciousdice ?? 0) const wpBonusAusp = Number(wParams.bonusauspiciousdice ?? 0)
@@ -375,7 +386,7 @@ export async function rollForActor(actor, rollKey) {
const wpSpecialtyBonus = wpItem.system.hasSpeciality ? 1 : 0 const wpSpecialtyBonus = wpItem.system.hasSpeciality ? 1 : 0
const wpTotalDice = wpSkillDice + wpAspectDice + wpRangeMalus + wpBonusMalus - wpWoundMalus + wpSpecialtyBonus const wpTotalDice = wpSkillDice + wpAspectDice + wpBonusMalus - wpWoundMalus + wpSpecialtyBonus
if (wpTotalDice <= 0) { if (wpTotalDice <= 0) {
ui.notifications.warn(game.i18n.localize("CDE.Error0")) ui.notifications.warn(game.i18n.localize("CDE.Error0"))
return return
@@ -390,7 +401,6 @@ export async function rollForActor(actor, rollKey) {
if (!wpResults) return if (!wpResults) return
const wpModParts = [] const wpModParts = []
if (wpRangeMalus !== 0) wpModParts.push(`${wpRangeMalus} ${game.i18n.localize("CDE.RangePenalty")}`)
if (wpBonusMalus !== 0) wpModParts.push(`${wpBonusMalus > 0 ? "+" : ""}${wpBonusMalus} ${game.i18n.localize("CDE.BonusMalus")}`) if (wpBonusMalus !== 0) wpModParts.push(`${wpBonusMalus > 0 ? "+" : ""}${wpBonusMalus} ${game.i18n.localize("CDE.BonusMalus")}`)
if (wpWoundMalus !== 0) wpModParts.push(`-${wpWoundMalus} ${game.i18n.localize("CDE.WoundMalus")}`) if (wpWoundMalus !== 0) wpModParts.push(`-${wpWoundMalus} ${game.i18n.localize("CDE.WoundMalus")}`)
if (wpBonusAusp !== 0) wpModParts.push(`+${wpBonusAusp} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`) if (wpBonusAusp !== 0) wpModParts.push(`+${wpBonusAusp} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`)
+111 -1
View File
@@ -11,7 +11,7 @@
* @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/ * @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
*/ */
import { MAGICS, SUBTYPES } from "../../../config/constants.js" import { MAGICS, SUBTYPES, ASPECT_NAMES, ASPECT_LABELS, ASPECT_ICONS, SYSTEM_ID } from "../../../config/constants.js"
import { rollInitiativePC } from "../../initiative.js" import { rollInitiativePC } from "../../initiative.js"
import { rollForActor } from "../../rolling.js" import { rollForActor } from "../../rolling.js"
import { CDEBaseActorSheet } from "./base.js" import { CDEBaseActorSheet } from "./base.js"
@@ -22,6 +22,7 @@ export class CDECharacterSheet extends CDEBaseActorSheet {
actions: { actions: {
moveMagicUp: CDECharacterSheet.#onMoveMagicUp, moveMagicUp: CDECharacterSheet.#onMoveMagicUp,
moveMagicDown: CDECharacterSheet.#onMoveMagicDown, moveMagicDown: CDECharacterSheet.#onMoveMagicDown,
recovery: CDECharacterSheet.#onRecovery,
}, },
} }
@@ -207,6 +208,115 @@ export class CDECharacterSheet extends CDEBaseActorSheet {
await this.document.update({ "system.magicOrder": order }) await this.document.update({ "system.magicOrder": order })
} }
static async #onRecovery(event, target) {
const actor = this.document
const sys = actor.system
const guardian = Number(sys.guardian ?? 0)
if (guardian < 1 || guardian > 5) {
ui.notifications.warn(game.i18n.localize("CDE.RecoveryNoGuardian"))
return
}
const aspectName = ASPECT_NAMES[guardian - 1]
const aspectLabel = game.i18n.localize(ASPECT_LABELS[aspectName])
const aspectValue = sys.aspect?.[aspectName]?.value ?? 0
if (aspectValue <= 0) {
ui.notifications.warn(game.i18n.localize("CDE.RecoveryNoAspect"))
return
}
// Show prompt for bonus dice
const RECOVERY_PROMPT = `systems/${SYSTEM_ID}/templates/form/cde-recovery-prompt.html`
const content = await foundry.applications.handlebars.renderTemplate(RECOVERY_PROMPT, {
basedice: aspectValue,
})
const params = await foundry.applications.api.DialogV2.prompt({
window: { title: game.i18n.localize("CDE.Recovery") },
content,
rejectClose: false,
ok: {
label: game.i18n.localize("CDE.Validate"),
callback: (event, button, dialog) => {
const root = dialog.element ?? dialog
return { bonusdice: Number(root.querySelector("[name=bonusdice]")?.value ?? 0) }
},
},
})
if (params === undefined) return
const totalDice = aspectValue + Math.max(0, Number(params.bonusdice ?? 0))
const roll = new Roll(`${totalDice}d10`)
await roll.evaluate()
// Count even (pair) and odd (impair) results
const results = roll.dice[0].results.map(r => r.result)
const evenCount = results.filter(r => r % 2 === 0).length
const oddCount = results.filter(r => r % 2 !== 0).length
// Clamp gains to configured max values. If max is 0 (unconfigured),
// allow free accumulation (no clamp).
const cap = (max) => (max > 0 ? max : Infinity)
const heiYang = Math.min(
(sys.threetreasures?.heiyang?.value ?? 0) + evenCount,
cap(sys.threetreasures?.heiyang?.max)
)
const heiYin = Math.min(
(sys.threetreasures?.heiyin?.value ?? 0) + oddCount,
cap(sys.threetreasures?.heiyin?.max)
)
const san = Math.min(
(sys.threetreasures?.dicelevel?.level0d?.san?.value ?? 0) + evenCount,
cap(sys.threetreasures?.dicelevel?.level0d?.san?.max)
)
const zing = Math.min(
(sys.threetreasures?.dicelevel?.level0d?.zing?.value ?? 0) + oddCount,
cap(sys.threetreasures?.dicelevel?.level0d?.zing?.max)
)
await actor.update({
"system.threetreasures.heiyang.value": heiYang,
"system.threetreasures.heiyin.value": heiYin,
"system.threetreasures.dicelevel.level0d.san.value": san,
"system.threetreasures.dicelevel.level0d.zing.value": zing,
})
const rollLabel = `${game.i18n.localize("CDE.Recovery")}${aspectLabel}`
const diceStr = results.join(" · ")
const typeOfThrow = Number(sys.prefs?.typeofthrow?.choice ?? 0)
const ROLL_MODES = ["roll", "gmroll", "blindroll", "selfroll"]
const rollMode = ROLL_MODES[typeOfThrow] ?? "roll"
await ChatMessage.create({
user: game.user.id,
speaker: ChatMessage.getSpeaker({ actor }),
content: `
<div class="cde-chat-recovery">
<div class="cde-chat-recovery-header">
<img src="${ASPECT_ICONS[aspectName]}" class="cde-chat-recovery-icon" alt="${aspectLabel}"/>
<span class="cde-chat-recovery-title">${rollLabel}</span>
</div>
<div class="cde-chat-recovery-dice">
<span class="cde-chat-recovery-dice-label">${totalDice}d10</span>
<span class="cde-chat-recovery-dice-results">${diceStr}</span>
</div>
<div class="cde-chat-recovery-results">
<div class="cde-chat-recovery-even">
<span class="cde-chat-recovery-count">${evenCount}</span>
<span><strong>${game.i18n.localize("CDE.RecoveryEvenLabel")}</strong></span>
<span>→ +${evenCount} Hei Yang, +${evenCount} San</span>
</div>
<div class="cde-chat-recovery-odd">
<span class="cde-chat-recovery-count">${oddCount}</span>
<span><strong>${game.i18n.localize("CDE.RecoveryOddLabel")}</strong></span>
<span>→ +${oddCount} Hei Yin, +${oddCount} Zing</span>
</div>
</div>
</div>`,
rolls: [roll],
rollMode,
})
}
#bindComponentRandomize() { #bindComponentRandomize() {
const btn = this.element?.querySelector("[data-action='randomize-component']") const btn = this.element?.querySelector("[data-action='randomize-component']")
if (!btn) return if (!btn) return
@@ -1,3 +1,10 @@
{{!-- Recovery button --}}
<div class="cde-recovery-bar">
<button type="button" class="cde-recovery-btn" data-action="recovery">
<i class="fas fa-heartbeat"></i> {{ localize "CDE.Recovery" }}
</button>
</div>
{{!-- HEI — Essence --}} {{!-- HEI — Essence --}}
<div class="tab-sous-titre">{{ localize "CDE.HEI" }} — {{ localize "CDE.Essence2" }}</div> <div class="tab-sous-titre">{{ localize "CDE.HEI" }} — {{ localize "CDE.Essence2" }}</div>
<div class="cde-tt-block"> <div class="cde-tt-block">
+13
View File
@@ -0,0 +1,13 @@
<div class="cde-roll-prompt cde-recovery-prompt {{cssClass}}">
<div class="cde-roll-header">
<span class="cde-roll-title">{{ localize "CDE.Recovery" }}</span>
<span class="cde-roll-dice-count">{{basedice}}<span class="cde-roll-dice-label">d10</span></span>
</div>
<div class="cde-roll-section">
<div class="cde-roll-field">
<label>{{ localize "CDE.RecoveryBaseDice" }}</label>
<input type="number" name="bonusdice" value="0" min="0" max="20" />
</div>
</div>
</div>
+3 -5
View File
@@ -41,11 +41,9 @@
<div class="cde-roll-field"> <div class="cde-roll-field">
<label>{{ localize "CDE.EffectiveRange" }}</label> <label>{{ localize "CDE.EffectiveRange" }}</label>
<select name="effectiverange"> <select name="effectiverange">
<option value="contact" {{#if (eq effectiverange "contact")}}selected{{/if}}>{{ localize "CDE.RangeContact" }} (±0)</option> {{#each validRanges}}
<option value="courte" {{#if (eq effectiverange "courte") }}selected{{/if}}>{{ localize "CDE.RangeCourte" }} (±0)</option> <option value="{{value}}" {{#if selected}}selected{{/if}}>{{ localize label }}</option>
<option value="mediane" {{#if (eq effectiverange "mediane")}}selected{{/if}}>{{ localize "CDE.RangeMediane" }} (1)</option> {{/each}}
<option value="longue" {{#if (eq effectiverange "longue") }}selected{{/if}}>{{ localize "CDE.RangeLongue" }} (2)</option>
<option value="extreme" {{#if (eq effectiverange "extreme")}}selected{{/if}}>{{ localize "CDE.RangeExtreme" }} (3)</option>
</select> </select>
</div> </div>
<div class="cde-roll-field"> <div class="cde-roll-field">