Gestion des traits d'arme et des munitions

This commit is contained in:
2026-04-21 18:50:49 +02:00
parent 74f1b581f7
commit 6ef454d533
22 changed files with 1448 additions and 195 deletions

View File

@@ -403,12 +403,18 @@ class WeaponData extends PhysicalItemData {
schema.damage = new fields$4.StringField({ required: false, blank: true, trim: true });
schema.magazine = new fields$4.NumberField({ required: false, initial: 0, min: 0, integer: true });
schema.magazineCost = new fields$4.NumberField({ required: false, initial: 0, min: 0, integer: true });
schema.traits = new fields$4.ArrayField(
new fields$4.SchemaField({
name: new fields$4.StringField({ required: true, blank: true, trim: true }),
description: new fields$4.StringField({ required: false, blank: true, trim: true })
})
);
schema.traits = new fields$4.SchemaField({
ap: new fields$4.NumberField({ required: false, initial: 0, min: 0, integer: true }),
auto: new fields$4.NumberField({ required: false, initial: 0, min: 0, integer: true }),
blast: new fields$4.NumberField({ required: false, initial: 0, min: 0, integer: true }),
bulky: new fields$4.BooleanField({ required: false, initial: false }),
veryBulky: new fields$4.BooleanField({ required: false, initial: false }),
stun: new fields$4.BooleanField({ required: false, initial: false }),
smart: new fields$4.BooleanField({ required: false, initial: false }),
radiation: new fields$4.BooleanField({ required: false, initial: false }),
scope: new fields$4.BooleanField({ required: false, initial: false }),
zeroG: new fields$4.BooleanField({ required: false, initial: false })
});
schema.options = new fields$4.ArrayField(
new fields$4.SchemaField({
name: new fields$4.StringField({ required: true, blank: true, trim: true }),
@@ -417,6 +423,23 @@ class WeaponData extends PhysicalItemData {
);
return schema;
}
/** Returns a compact display string of active traits (e.g. "AP 2, Auto 3, Blast 5, Bulky") */
static getTraitsSummary(traits) {
if (!traits) return "";
const parts = [];
if (traits.ap > 0) parts.push(`AP ${traits.ap}`);
if (traits.auto > 0) parts.push(`Auto ${traits.auto}`);
if (traits.blast > 0) parts.push(`Blast ${traits.blast}`);
if (traits.bulky) parts.push(game.i18n.localize("MGT2.WeaponTraits.Bulky"));
if (traits.veryBulky) parts.push(game.i18n.localize("MGT2.WeaponTraits.VeryBulky"));
if (traits.stun) parts.push(game.i18n.localize("MGT2.WeaponTraits.Stun"));
if (traits.smart) parts.push(game.i18n.localize("MGT2.WeaponTraits.Smart"));
if (traits.radiation) parts.push(game.i18n.localize("MGT2.WeaponTraits.Radiation"));
if (traits.scope) parts.push(game.i18n.localize("MGT2.WeaponTraits.Scope"));
if (traits.zeroG) parts.push(game.i18n.localize("MGT2.WeaponTraits.ZeroG"));
return parts.join(", ");
}
}
const fields$3 = foundry.data.fields;
@@ -943,7 +966,10 @@ class ActorCharacter {
}
updateData["system.inventory.weight"] = onHandWeight;
updateData["system.states.encumbrance"] = onHandWeight > $this.system.inventory.encumbrance.normal;
// Use the threshold from updateData if it was just recalculated (e.g. STR/END talent change),
// otherwise fall back to the persisted value.
const encumbranceThreshold = updateData["system.inventory.encumbrance.normal"] ?? $this.system.inventory.encumbrance.normal;
updateData["system.states.encumbrance"] = onHandWeight > encumbranceThreshold;
await $this.update(updateData);
@@ -964,6 +990,8 @@ class ActorCharacter {
let heavy = normal * 2;
foundry.utils.setProperty(changed, "system.inventory.encumbrance.normal", normal);
foundry.utils.setProperty(changed, "system.inventory.encumbrance.heavy", heavy);
// Also update the encumbrance state flag against the new threshold
foundry.utils.setProperty(changed, "system.states.encumbrance", $this.system.inventory.weight > normal);
}
//console.log(foundry.utils.getProperty(changed, "system.characteristics.strength.value"));
@@ -1017,25 +1045,39 @@ class ActorCharacter {
// $this.update({ system: { characteristics: data } });
// }
static applyDamage($this, amount, { ignoreArmor = false } = {}) {
if (isNaN(amount) || amount === 0) return;
static async applyDamage($this, amount, { ignoreArmor = false, ap = 0, stun = false } = {}) {
if (isNaN(amount) || amount === 0) return { incapRounds: 0 };
const rank1 = $this.system.config.damages.rank1;
const rank2 = $this.system.config.damages.rank2;
const rank3 = $this.system.config.damages.rank3;
if (amount < 0) amount = Math.abs(amount);
if (!ignoreArmor) {
const rawArmor = $this.system.inventory?.armor ?? 0;
const armorValue = Math.max(0, rawArmor - ap);
amount = Math.max(0, amount - armorValue);
if (amount === 0) return { incapRounds: 0 };
}
// ── Stun / Incapacitating: only deduct from endurance (rank3) ─────────
if (stun) {
const endKey = rank3; // "endurance" by default
const prevEnd = $this.system.characteristics[endKey].value;
const newEnd = Math.max(0, prevEnd - amount);
const incapRounds = newEnd === 0 ? Math.max(0, amount - prevEnd) : 0;
await $this.update({
system: { characteristics: { [endKey]: { value: newEnd, dm: this.getModifier(newEnd) } } }
});
return { incapRounds };
}
// ── Normal damage cascade: rank1 → rank2 → rank3 ────────────────────
const data = {};
data[rank1] = { value: $this.system.characteristics[rank1].value };
data[rank2] = { value: $this.system.characteristics[rank2].value };
data[rank3] = { value: $this.system.characteristics[rank3].value };
if (amount < 0) amount = Math.abs(amount);
if (!ignoreArmor) {
const armorValue = $this.system.inventory?.armor ?? 0;
amount = Math.max(0, amount - armorValue);
if (amount === 0) return;
}
for (const [key, rank] of Object.entries(data)) {
if (rank.value > 0) {
if (rank.value >= amount) {
@@ -1050,7 +1092,8 @@ class ActorCharacter {
}
}
$this.update({ system: { characteristics: data } });
await $this.update({ system: { characteristics: data } });
return { incapRounds: 0 };
}
static applyHealing($this, amount, type) {
@@ -1237,17 +1280,18 @@ class TravellerActor extends Actor {
}
}
applyDamage(amount, { ignoreArmor = false } = {}) {
applyDamage(amount, { ignoreArmor = false, ap = 0, stun = false } = {}) {
if (this.type === "character") {
return ActorCharacter.applyDamage(this, amount, { ignoreArmor });
return ActorCharacter.applyDamage(this, amount, { ignoreArmor, ap, stun });
} else if (this.type === "creature") {
if (isNaN(amount) || amount === 0) return;
if (isNaN(amount) || amount === 0) return Promise.resolve({ incapRounds: 0 });
if (amount < 0) amount = Math.abs(amount);
const armorValue = ignoreArmor ? 0 : (this.system.armor ?? 0);
const rawArmor = ignoreArmor ? 0 : (this.system.armor ?? 0);
const armorValue = Math.max(0, rawArmor - ap);
const effective = Math.max(0, amount - armorValue);
if (effective === 0) return;
if (effective === 0) return Promise.resolve({ incapRounds: 0 });
const newValue = Math.max(0, (this.system.life.value ?? 0) - effective);
return this.update({ "system.life.value": newValue });
return this.update({ "system.life.value": newValue }).then(() => ({ incapRounds: 0 }));
}
}
@@ -1722,7 +1766,14 @@ class RollPromptHelper {
skillLevel: options.skillLevel ?? 0,
// Healing fields
showHeal: options.showHeal ?? false,
healType: options.healType ?? null
healType: options.healType ?? null,
// Ranged/Melee weapon flags
isRanged: options.isRanged ?? false,
isMelee: options.isMelee ?? false,
isAttack: (options.isRanged ?? false) || (options.isMelee ?? false),
autoLevel: options.autoLevel ?? 0,
hasScope: options.scope ?? false,
hasZeroG: options.zeroG ?? false,
});
return await DialogV2$1.wait({
@@ -1816,13 +1867,14 @@ class CharacterPrompts {
}
static async openEditorFullView(title, html) {
const safeTitle = title || game.i18n.localize("MGT2.Actor.Species") || "Species";
const htmlContent = await renderTemplate$2("systems/mgt2/templates/editor-fullview.html", {
config: CONFIG.MGT2,
html
html: html ?? ""
});
game.settings.get("mgt2", "theme");
await DialogV2.wait({
window: { title },
window: { title: safeTitle },
content: htmlContent,
rejectClose: false,
buttons: [
@@ -2064,8 +2116,7 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
i._range = i.system.range.isMelee
? game.i18n.localize("MGT2.Melee")
: MGT2Helper.getRangeDisplay(i.system.range);
if (i.system.traits?.length > 0)
i._subInfo = i.system.traits.map(x => x.name).join(", ");
i._subInfo = WeaponData.getTraitsSummary(i.system.traits);
weapons.push(i);
break;
@@ -2453,7 +2504,14 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
encumbrance: this.actor.system.states.encumbrance,
difficulty: null,
damageFormula: null,
damageAP: 0,
blastRadius: 0,
stun: false,
radiation: false,
isMelee: false,
isRanged: false,
bulky: false,
veryBulky: false,
};
const cardButtons = [];
@@ -2523,6 +2581,18 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
rollOptions.damageFormula = itemObj.system.damage;
if (itemObj.type === "weapon") {
rollOptions.isMelee = itemObj.system.range?.isMelee === true;
rollOptions.isRanged = itemObj.type === "weapon" && !itemObj.system.range?.isMelee;
rollOptions.damageAP = itemObj.system.traits?.ap ?? 0;
rollOptions.blastRadius = itemObj.system.traits?.blast ?? 0;
rollOptions.stun = itemObj.system.traits?.stun === true;
rollOptions.radiation = itemObj.system.traits?.radiation === true;
rollOptions.scope = itemObj.system.traits?.scope === true;
rollOptions.zeroG = itemObj.system.traits?.zeroG === true;
rollOptions.autoLevel = itemObj.system.traits?.auto ?? 0;
rollOptions.itemId = itemObj._id;
rollOptions.magazine = itemObj.system.magazine ?? -1; // -1 = not tracked
rollOptions.bulky = itemObj.system.traits?.bulky === true;
rollOptions.veryBulky = itemObj.system.traits?.veryBulky === true;
}
if (itemObj.type === "disease") {
if (itemObj.system.subType === "disease")
@@ -2544,6 +2614,10 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
const rollModifiers = [];
const rollFormulaParts = [];
// Auto trait — fire mode
const autoLevel = rollOptions.autoLevel ?? 0;
const autoMode = autoLevel > 0 ? (userRollData.autoMode ?? "single") : "single";
if (userRollData.diceModifier) {
rollFormulaParts.push("3d6", userRollData.diceModifier);
} else {
@@ -2594,14 +2668,127 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.CustomDM") + " " + (customDMVal > 0 ? `+${customDMVal}` : `${customDMVal}`));
}
if (rollOptions.isRanged) {
const rangedRange = parseInt(userRollData.rangedRange ?? "0", 10);
if (rangedRange === 1) {
rollFormulaParts.push("+1");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.RangeShort") + " +1");
} else if (rangedRange === -2) {
rollFormulaParts.push("-2");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.RangeLong") + " 2");
} else if (rangedRange === -4) {
rollFormulaParts.push("-4");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.RangeExtreme") + " 4");
}
const rangedAim = parseInt(userRollData.rangedAim ?? "0", 10);
// Auto: burst/full-auto cancels all aiming advantages (rules p.75)
if (rangedAim > 0 && autoMode === "single") {
rollFormulaParts.push(`+${rangedAim}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Aim") + ` +${rangedAim}`);
if (userRollData.rangedLaserSight === true || userRollData.rangedLaserSight === "true") {
rollFormulaParts.push("+1");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.LaserSight") + " +1");
}
}
const rangedFastTarget = parseInt(userRollData.rangedFastTarget ?? "0", 10);
if (rangedFastTarget < 0) {
rollFormulaParts.push(`${rangedFastTarget}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.FastTarget") + ` ${rangedFastTarget}`);
}
if (userRollData.rangedCover === true || userRollData.rangedCover === "true") {
rollFormulaParts.push("-2");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Cover") + " 2");
}
if (userRollData.rangedProne === true || userRollData.rangedProne === "true") {
rollFormulaParts.push("-1");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Prone") + " 1");
}
if (userRollData.rangedDodge === true || userRollData.rangedDodge === "true") {
const dodgeDM = parseInt(userRollData.rangedDodgeDM ?? "0", 10);
if (dodgeDM < 0) {
rollFormulaParts.push(`${dodgeDM}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Dodge") + ` ${dodgeDM}`);
}
}
}
if (rollOptions.isMelee) {
if (userRollData.meleeDodge === true || userRollData.meleeDodge === "true") {
const dodgeDM = parseInt(userRollData.meleeDodgeDM ?? "0", 10);
if (dodgeDM < 0) {
rollFormulaParts.push(`${dodgeDM}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Dodge") + ` ${dodgeDM}`);
}
}
if (userRollData.meleeParry === true || userRollData.meleeParry === "true") {
const parryDM = parseInt(userRollData.meleeParryDM ?? "0", 10);
if (parryDM < 0) {
rollFormulaParts.push(`${parryDM}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Parry") + ` ${parryDM}`);
}
}
}
if (MGT2Helper.hasValue(userRollData, "difficulty") && userRollData.difficulty !== "") rollOptions.difficulty = userRollData.difficulty;
// ── Bulky / Very Bulky trait: STR penalty ────────────────────────────
const strDm = this.actor.system.characteristics.strength?.dm ?? 0;
if (rollOptions.veryBulky) {
// Very Bulky: requires STR DM ≥ +2
if (strDm < 2) {
const penalty = strDm - 2;
rollFormulaParts.push(`${penalty}`);
rollModifiers.push(game.i18n.localize("MGT2.WeaponTraits.VeryBulky") + ` ${penalty}`);
ui.notifications.warn(game.i18n.format("MGT2.Notifications.VeryBulkyPenalty", { penalty }));
}
} else if (rollOptions.bulky) {
// Bulky: requires STR DM ≥ +1
if (strDm < 1) {
const penalty = strDm - 1;
rollFormulaParts.push(`${penalty}`);
rollModifiers.push(game.i18n.localize("MGT2.WeaponTraits.Bulky") + ` ${penalty}`);
ui.notifications.warn(game.i18n.format("MGT2.Notifications.BulkyPenalty", { penalty }));
}
}
const rollFormula = rollFormulaParts.join("");
if (!Roll.validate(rollFormula)) {
ui.notifications.error(game.i18n.localize("MGT2.Errors.InvalidRollFormula"));
return;
}
// ── Ammo decrement (ranged weapons with magazine tracking) ───────────
if (rollOptions.isRanged && rollOptions.itemId && rollOptions.magazine >= 0) {
let ammoUsed = 1;
if (autoMode === "burst" && autoLevel > 0) ammoUsed = autoLevel;
else if (autoMode === "fullAuto" && autoLevel > 0) ammoUsed = autoLevel * 3;
const currentMag = rollOptions.magazine;
if (currentMag <= 0) {
ui.notifications.warn(
game.i18n.format("MGT2.Notifications.NoAmmo", { weapon: rollOptions.rollObjectName })
);
} else {
const newMag = Math.max(0, currentMag - ammoUsed);
const weaponItem = this.actor.getEmbeddedDocument("Item", rollOptions.itemId);
if (weaponItem) await weaponItem.update({ "system.magazine": newMag });
if (newMag === 0) {
ui.notifications.warn(
game.i18n.format("MGT2.Notifications.AmmoEmpty", { weapon: rollOptions.rollObjectName })
);
} else {
ui.notifications.info(
game.i18n.format("MGT2.Notifications.AmmoUsed", { used: ammoUsed, remaining: newMag, weapon: rollOptions.rollObjectName })
);
}
}
}
let roll = await new Roll(rollFormula, this.actor.getRollData()).roll({ rollMode: userRollData.rollMode });
if (isInitiative && this.token?.combatant) {
@@ -2623,7 +2810,7 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
rollFailure = !rollSuccess;
}
// Build effective damage formula: base + effect + STR DM (melee)
// Build effective damage formula: base + effect + STR DM (melee) + Auto burst
let effectiveDamageFormula = rollOptions.damageFormula || null;
if (effectiveDamageFormula) {
if (rollEffect !== undefined && rollEffect !== 0) {
@@ -2633,6 +2820,18 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
const strDm = this.actor.system.characteristics.strength?.dm ?? 0;
if (strDm !== 0) effectiveDamageFormula += (strDm >= 0 ? "+" : "") + strDm;
}
// Burst: add Auto level to damage
if (autoMode === "burst" && autoLevel > 0) {
effectiveDamageFormula += `+${autoLevel}`;
}
}
// Auto fire mode chat info
let autoInfo = null;
if (autoMode === "burst" && autoLevel > 0) {
autoInfo = game.i18n.format("MGT2.RollPrompt.AutoBurstInfo", { level: autoLevel, ammo: autoLevel });
} else if (autoMode === "fullAuto" && autoLevel > 0) {
autoInfo = game.i18n.format("MGT2.RollPrompt.AutoFullInfo", { level: autoLevel, ammo: autoLevel * 3 });
}
// ── Build roll breakdown tooltip ─────────────────────────────────────
@@ -2659,6 +2858,7 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
// Show damage button only if there's a formula AND (no difficulty check OR roll succeeded)
showRollDamage: !!effectiveDamageFormula && (!difficultyValue || rollSuccess),
cardButtons: cardButtons,
autoInfo,
};
if (MGT2Helper.hasValue(rollOptions, "difficulty")) {
@@ -2675,7 +2875,7 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
let flags = null;
if (effectiveDamageFormula) {
flags = { mgt2: { damage: { formula: effectiveDamageFormula, rollObjectName: rollOptions.rollObjectName, rollTypeName: rollOptions.rollTypeName } } };
flags = { mgt2: { damage: { formula: effectiveDamageFormula, ap: rollOptions.damageAP ?? 0, blast: rollOptions.blastRadius ?? 0, stun: rollOptions.stun ?? false, radiation: rollOptions.radiation ?? false, rollObjectName: rollOptions.rollObjectName, rollTypeName: rollOptions.rollTypeName } } };
}
if (cardButtons.length > 0) {
if (!flags) flags = { mgt2: {} };
@@ -2759,17 +2959,18 @@ class TravellerCharacterSheet extends MGT2ActorSheet {
static async #onOpenEditor(event) {
event.preventDefault();
await CharacterPrompts.openEditorFullView(
this.actor.system.personal.species,
this.actor.system.personal.speciesText.descriptionLong
);
const title = this.actor.system.personal.species
|| game.i18n.localize("MGT2.Actor.Species")
|| "Species";
const html = this.actor.system.personal.speciesText?.descriptionLong ?? "";
await CharacterPrompts.openEditorFullView(title, html);
}
static async #onHeal(event, target) {
event.preventDefault();
const healType = target.dataset.healType;
if (canvas.tokens.controlled.length === 0) {
if (game.user.targets.size === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
}
@@ -3214,7 +3415,7 @@ class TravellerCreatureSheet extends MGT2ActorSheet {
// ───────────────────────────────────────────────────────── Roll Helpers
static async #postCreatureRoll({ actor, roll, rollLabel, dm, difficulty, difficultyLabel, rollMode, extraTooltip }) {
static async #postCreatureRoll({ actor, roll, rollLabel, dm, difficulty, difficultyLabel, rollMode, extraTooltip, damageFormula }) {
const diffTarget = MGT2Helper.getDifficultyValue(difficulty ?? "Average");
const hasDifficulty = !!difficulty;
const success = hasDifficulty ? roll.total >= diffTarget : true;
@@ -3228,6 +3429,8 @@ class TravellerCreatureSheet extends MGT2ActorSheet {
if (extraTooltip) breakdownParts.push(extraTooltip);
const rollBreakdown = breakdownParts.join(" | ");
const showRollDamage = success && !!damageFormula;
const chatData = {
creatureName: actor.name,
creatureImg: actor.img,
@@ -3243,6 +3446,7 @@ class TravellerCreatureSheet extends MGT2ActorSheet {
effect: hasDifficulty ? effect : null,
effectStr: hasDifficulty ? effectStr : null,
modifiers: dm !== 0 ? [`DM ${dm >= 0 ? "+" : ""}${dm}`] : [],
showRollDamage,
};
const chatContent = await renderTemplate$1(
@@ -3250,11 +3454,23 @@ class TravellerCreatureSheet extends MGT2ActorSheet {
chatData
);
const flags = showRollDamage ? {
mgt2: {
damage: {
formula: normalizeDice(damageFormula),
effect,
rollObjectName: actor.name,
rollTypeName: rollLabel,
}
}
} : {};
await ChatMessage.create({
content: chatContent,
speaker: ChatMessage.getSpeaker({ actor }),
rolls: [roll],
rollMode: rollMode ?? game.settings.get("core", "rollMode"),
flags,
});
return { success, effect, total: roll.total };
@@ -3355,24 +3571,14 @@ class TravellerCreatureSheet extends MGT2ActorSheet {
if (chosenSkill) tooltipParts.push(`${chosenSkill.name} ${skillLevel >= 0 ? "+" : ""}${skillLevel}`);
if (customDM !== 0) tooltipParts.push(`MD perso ${customDM >= 0 ? "+" : ""}${customDM}`);
const { success } = await TravellerCreatureSheet.#postCreatureRoll({
await TravellerCreatureSheet.#postCreatureRoll({
actor, roll, rollLabel,
dm,
difficulty: result.difficulty,
rollMode: result.rollMode,
extraTooltip: tooltipParts.join(" | "),
damageFormula: attack.damage || null,
});
// Roll damage only on success
if (success && attack.damage) {
const dmgFormula = normalizeDice(attack.damage);
const dmgRoll = await new Roll(dmgFormula).evaluate();
await dmgRoll.toMessage({
speaker: ChatMessage.getSpeaker({ actor }),
flavor: `<strong>${actor.name}</strong> — ${game.i18n.localize("MGT2.Chat.Weapon.Damage")}: ${attack.name} (${attack.damage})`,
rollMode: result.rollMode ?? game.settings.get("core", "rollMode"),
});
}
}
// ───────────────────────────────────────────────────────── CRUD Handlers
@@ -3716,6 +3922,7 @@ const preloadHandlebarsTemplates = async function() {
"systems/mgt2/templates/roll-prompt.html",
"systems/mgt2/templates/chat/roll.html",
//"systems/mgt2/templates/chat/roll-characteristic.html",
"systems/mgt2/templates/chat/radiation.html",
"systems/mgt2/templates/actors/actor-config-sheet.html",
"systems/mgt2/templates/actors/actor-config-characteristic-sheet.html",
"systems/mgt2/templates/actors/trait-sheet.html",
@@ -3734,12 +3941,45 @@ class ChatHelper {
if (!message || !element) {
return;
}
// Restore disabled state for already-applied buttons
const appliedActions = message.flags?.mgt2?.appliedActions ?? [];
if (appliedActions.length > 0) {
appliedActions.forEach(action => {
element.querySelectorAll(`button[data-action="${action}"]`).forEach(btn => {
btn.disabled = true;
});
});
}
// Apply buttons are GM-only: hide them for players
const GM_ACTIONS = ["damage", "healing", "surgeryDamage"];
if (!game.user.isGM) {
GM_ACTIONS.forEach(action => {
element.querySelectorAll(`button[data-action="${action}"]`).forEach(btn => {
btn.style.display = "none";
});
});
// Hide the buttons container if no visible buttons remain
element.querySelectorAll(".mgt2-buttons").forEach(container => {
const hasVisible = [...container.querySelectorAll("button")]
.some(b => b.style.display !== "none");
if (!hasVisible) container.style.display = "none";
});
}
element.querySelectorAll('button[data-action="rollDamage"]').forEach(el => {
el.addEventListener('click', async event => {
await this._processRollDamageButtonEvent(message, event);
});
});
element.querySelectorAll('button[data-action="rollRadiation"]').forEach(el => {
el.addEventListener('click', async event => {
await this._rollRadiationDamage(message, event);
});
});
element.querySelectorAll('button[data-action="damage"]').forEach(el => {
el.addEventListener('click', async event => {
await this._applyChatCardDamage(message, event);
@@ -3791,75 +4031,144 @@ class ChatHelper {
static async _processRollDamageButtonEvent(message, event) {
event.preventDefault();
event.stopPropagation();
let rollFormula = message.flags.mgt2.damage.formula;
let roll = await new Roll(rollFormula, {}).roll();
let speaker;
let selectTokens = canvas.tokens.controlled;
if (selectTokens.length > 0) {
speaker = selectTokens[0].actor;
} else {
speaker = game.user.character;
const damageFlags = message.flags?.mgt2?.damage;
if (!damageFlags?.formula) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.InvalidRollFormula"));
return;
}
let rollTypeName = message.flags.mgt2.damage.rollTypeName ? message.flags.mgt2.damage.rollTypeName + " " + game.i18n.localize("MGT2.Actor.Damage") : null;
const effect = damageFlags.effect ?? 0;
const effectPart = effect > 0 ? `+${effect}` : effect < 0 ? `${effect}` : "";
const rollFormula = damageFlags.formula + effectPart;
let roll;
try {
roll = await new Roll(rollFormula, {}).roll();
} catch (e) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.InvalidRollFormula"));
return;
}
const rollTypeName = damageFlags.rollTypeName
? damageFlags.rollTypeName + " " + game.i18n.localize("MGT2.Actor.Damage")
: null;
const ap = damageFlags.ap ?? 0;
const blast = damageFlags.blast ?? 0;
const stun = damageFlags.stun ?? false;
const radiation = damageFlags.radiation ?? false;
const chatData = {
user: game.user.id,
speaker: ChatMessage.getSpeaker({ actor: speaker }),
speaker: message.speaker,
formula: roll._formula,
tooltip: await roll.getTooltip(),
total: Math.round(roll.total * 100) / 100,
showButtons: true,
hasDamage: true,
rollTypeName: rollTypeName,
rollObjectName: message.flags.mgt2.damage.rollObjectName
rollTypeName,
rollObjectName: damageFlags.rollObjectName,
apValue: ap > 0 ? ap : null,
blastRadius: blast > 0 ? blast : null,
stunWeapon: stun || null,
radiationWeapon: radiation || null,
};
const html = await foundry.applications.handlebars.renderTemplate("systems/mgt2/templates/chat/roll.html", chatData);
chatData.content = html;
// Persist ap, blast, stun, radiation in damage message flags so handlers can read them
if (ap > 0 || blast > 0 || stun || radiation) chatData.flags = { mgt2: { damage: { ap, blast, stun, radiation, rollObjectName: damageFlags.rollObjectName } } };
return roll.toMessage(chatData);
}
static _applyChatCardDamage(message, event) {
if (canvas.tokens.controlled.length === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
static async #markButtonApplied(message, btn, action) {
const existing = message.flags?.mgt2?.appliedActions ?? [];
if (!existing.includes(action)) {
await message.setFlag("mgt2", "appliedActions", [...existing, action]);
}
const roll = message.rolls[0];
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyDamage(roll.total);
}));
if (btn) btn.disabled = true;
}
static _applyChatCardHealing(message, event) {
if (canvas.tokens.controlled.length === 0) {
static async _applyChatCardDamage(message, event) {
if (game.user.targets.size === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
}
// For First Aid/Surgery healing, use amount from flags; otherwise use roll total
const btn = event.currentTarget;
const roll = message.rolls[0];
const amount = Math.round(roll.total * 100) / 100;
const ap = message.flags?.mgt2?.damage?.ap ?? 0;
const stun = message.flags?.mgt2?.damage?.stun ?? false;
await Promise.all([...game.user.targets].map(async t => {
const result = await t.actor.applyDamage(amount, { ap, stun });
if (stun) {
const incapRounds = result?.incapRounds ?? 0;
if (incapRounds > 0) {
ui.notifications.warn(game.i18n.format("MGT2.Notifications.StunIncapacitated", { name: t.actor.name, rounds: incapRounds }));
} else {
ui.notifications.info(game.i18n.format("MGT2.Notifications.StunDamageApplied", { name: t.actor.name, amount }));
}
} else {
const notifKey = ap > 0 ? "MGT2.Notifications.DamageAppliedAP" : "MGT2.Notifications.DamageApplied";
ui.notifications.info(game.i18n.format(notifKey, { name: t.actor.name, amount, ap }));
}
}));
await ChatHelper.#markButtonApplied(message, btn, "damage");
}
static async _applyChatCardHealing(message, event) {
if (game.user.targets.size === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
}
const btn = event.currentTarget;
const amount = message.flags?.mgt2?.healing?.amount
?? message.flags?.mgt2?.surgery?.healing
?? Math.max(1, message.rolls[0].total);
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyHealing(amount);
await Promise.all([...game.user.targets].map(async t => {
await t.actor.applyHealing(amount);
ui.notifications.info(game.i18n.format("MGT2.Notifications.HealingApplied", { name: t.actor.name, amount }));
}));
await ChatHelper.#markButtonApplied(message, btn, "healing");
}
static _applyChatCardSurgeryDamage(message, event) {
if (canvas.tokens.controlled.length === 0) {
static async _applyChatCardSurgeryDamage(message, event) {
if (game.user.targets.size === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
}
const btn = event.currentTarget;
const amount = message.flags?.mgt2?.surgery?.surgeryDamage ?? 3;
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyDamage(amount, { ignoreArmor: true });
await Promise.all([...game.user.targets].map(async t => {
await t.actor.applyDamage(amount, { ignoreArmor: true });
ui.notifications.info(game.i18n.format("MGT2.Notifications.DamageApplied", { name: t.actor.name, amount }));
}));
await ChatHelper.#markButtonApplied(message, btn, "surgeryDamage");
}
static async _rollRadiationDamage(message, event) {
event.preventDefault();
event.stopPropagation();
const damageFlags = message.flags?.mgt2?.damage;
const rollObjectName = damageFlags?.rollObjectName ?? "";
// 2D × 20 rads (MGT2 rule: 2d6 × 20)
const roll = await new Roll("2d6 * 20", {}).roll();
const chatData = {
user: game.user.id,
speaker: message.speaker,
formula: roll._formula,
tooltip: await roll.getTooltip(),
total: Math.round(roll.total),
rollObjectName,
};
const html = await foundry.applications.handlebars.renderTemplate("systems/mgt2/templates/chat/radiation.html", chatData);
chatData.content = html;
return roll.toMessage(chatData);
}
}