Compare commits

..

8 Commits

Author SHA1 Message Date
uberwald 05c93f9475 Full reroll management
Release Creation / build (release) Successful in 43s
2026-06-28 08:39:55 +02:00
uberwald bb005ee9fc feat: full reroll includes D30 + shows dice breakdown in chat
- Remove forceNoD30 from rerollConfiguredRoll so mulligan
  rerolls the D30 along with the d20 and modifier
- Reset defenseD30Processed/attackD30Processed after mulligan
  so the new D30's effects (bonus dice, specials) are applied
- Render reroll dice breakdown (diceResults + D30 result) as
  inline HTML in the reaction chat message using existing
  CSS classes so players see what was rolled
2026-06-28 08:04:20 +02:00
uberwald fa5c4cc9ce debug: log favor/disfavor dice totals and results for RNG investigation
9/12 identical d20 results under disfavor suggests RNG issue.
Add structured logging of both roll totals, per-die results,
and baseFormula. Enable debug mode in system settings to see
output in browser console.
2026-06-22 22:33:00 +02:00
uberwald 3b0d4e032e fix: cache results.length before explosion loop to prevent double-count
Release Creation / build (release) Successful in 50s
Pushing explosion dice to DieTerm.results made the for loop
condition (j < results.length) grow mid-iteration, re-processing
the explosion result as a normal die. This produced spurious
entries like `1D6 → 4` alongside the correct `1D6-1 → 3`.

Fixes prompt() (for loop) and rollSpellDamageToMessage()
(for...of) by caching result count / snapshotting the array
before iterating.
2026-06-16 19:34:13 +02:00
uberwald 539841c4ff fix: push explosion dice to DieTerm results so DSN displays them
Explosion rolls were evaluated as separate Roll instances but never
added to the original DieTerm's results array. Dice So Nice reads
DieTerm.results to render 3D dice, so explosions were invisible.

Now each explosion result is pushed into the DieTerm's results array
({result, active:true}), letting DSN render explosion dice in the
correct chronological order alongside the main die.

Applies to prompt(), promptRangedDefense(), promptRangedAttack(),
and rollSpellDamageToMessage().
2026-06-16 19:29:07 +02:00
uberwald ffba37b59e Fix D30 management, again 2026-06-14 23:00:39 +02:00
uberwald 1a7585e1f6 fix: merge saving_throws D30 table into arcane_spell_defense
Release Creation / build (release) Successful in 49s
saving_throws was redundant — all saves in this system are
vs spells. Removed SAVING_THROWS constant; all save rollType
lookups use ARCANE_SPELL_DEFENSE. D30=1 arcane_spell_defense
blank (no special result). Added miracle types to ARCANE_SPELL_ATTACK
mapping so they get D30 results instead of null.
2026-06-14 22:57:41 +02:00
uberwald b567c8bbea Fix D30 management, again
Release Creation / build (release) Successful in 48s
2026-06-13 23:15:22 +02:00
6 changed files with 132 additions and 124 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
token: ${{ secrets.FOUNDRY_PUBLISH_KEY }}
id: "fvtt-lethal-fantasy"
version: ${{github.event.release.tag_name}}
manifest: "https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{github.event.release.tag_name}}/system.json"
manifest: "https://www.uberwald.me/gitea/uberwald/fvtt-lethal-fantasy/releases/download/latest/system.json"
notes: "https://www.uberwald.me/gitea/public/fvtt-lethal-fantasy/raw/branch/main/changelog.md"
compatibility-minimum: "14"
compatibility-verified: "14"
+46 -5
View File
@@ -553,6 +553,17 @@ Hooks.on("preCreateChatMessage", (message) => {
}
})
// Build dice breakdown HTML from a reroll result
function formatRerollBreakdown(reroll) {
const breakdown = (reroll.options?.diceResults || [])
.map(r => `<span class="dice-item"><span class="dice-type">${r.dice}</span><span class="dice-separator">→</span><span class="dice-value">${r.value}</span></span>`)
.join("")
const d30 = reroll.options?.D30message
? `<div class="d30-result"><span class="d30-value">D30 → ${reroll.options.D30result || "?"}</span> — ${reroll.options.D30message.description}</div>`
: ""
return { breakdown, d30 }
}
// Hook global pour gérer l'offre de Grit à l'attaquant après une défense
Hooks.on("createChatMessage", async (message) => {
const rollType = message.rolls[0]?.options?.rollType
@@ -580,15 +591,15 @@ Hooks.on("createChatMessage", async (message) => {
attackWeaponId,
attackRollType,
attackRollKey,
attackD30message,
attackRerollContext,
attackNaturalRoll,
damageTier,
defenderId,
defenderTokenId
} = attackData
let { attackD30message } = attackData
let defenseRoll = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0
const defenseD30message = message.rolls[0]?.options?.D30message || null
let defenseD30message = message.rolls[0]?.options?.D30message || null
log("Processing defense:", { attackRoll, defenseRoll, attackerId, defenderId })
@@ -806,7 +817,18 @@ Hooks.on("createChatMessage", async (message) => {
canRerollDefense = false
if (!reroll) continue
defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll
await createReactionMessage(defender, `<p><strong>${defenderName}</strong> uses Mulligan and re-rolls defense: <strong>${oldDefenseRoll}</strong> → <strong>${defenseRoll}</strong>. Both sides may now react to the new numbers.</p>`)
// Build dice breakdown HTML from the reroll
const { breakdown: rerollBreakdown, d30: rerollD30 } = formatRerollBreakdown(reroll)
await createReactionMessage(defender,
`<p><strong>${defenderName}</strong> uses Mulligan and re-rolls defense: <strong>${oldDefenseRoll}</strong> → <strong>${defenseRoll}</strong>.</p>
<div class="dice-breakdown">${rerollBreakdown}</div>${rerollD30}
<p>Both sides may now react to the new numbers.</p>`
)
// Apply new D30 result on the restart
if (reroll.options?.D30message) {
defenseD30message = reroll.options.D30message
defenseD30Processed = false
}
// Restart the full comparison so both sides can react to the new roll
mulliganRestart = true
break
@@ -873,6 +895,7 @@ Hooks.on("createChatMessage", async (message) => {
// ── D30 bonus dice (attack) — resolved before grit/luck ────────────────
if (attackD30message && !attackD30Processed) {
const preD30AttackRoll = attackRollFinal
const canDialog = isPrimaryController(attacker)
const d30Result = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, canDialog)
if (d30Result.modifier) {
@@ -913,10 +936,17 @@ Hooks.on("createChatMessage", async (message) => {
}
}
attackD30Processed = true
// If D30 boosted attack past defense, restart so defender can react.
// Only restart when D30 actually changed the outcome (pre-D30 defender was
// winning or tied, post-D30 defender is losing).
if (defender && preD30AttackRoll <= defenseRoll && defenseRoll < attackRollFinal) {
mulliganRestart = true
continue
}
}
// ── Attack reaction loop ───────────────────────────────────────────────
if (!defenderHandledBonus && attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) {
if (attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) {
while (attackRollFinal <= defenseRoll) {
const currentGrit = Number(attacker.system?.grit?.current) || 0
const buttons = []
@@ -995,7 +1025,18 @@ Hooks.on("createChatMessage", async (message) => {
canRerollAttack = false
if (!reroll) continue
attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll
await createReactionMessage(attacker, `<p><strong>${attackerName}</strong> uses Mulligan and re-rolls attack: <strong>${oldAttackRoll}</strong> → <strong>${attackRollFinal}</strong>. Both sides may now react to the new numbers.</p>`)
// Build dice breakdown HTML from the reroll
const { breakdown: rerollBreakdown, d30: rerollD30 } = formatRerollBreakdown(reroll)
await createReactionMessage(attacker,
`<p><strong>${attackerName}</strong> uses Mulligan and re-rolls attack: <strong>${oldAttackRoll}</strong> → <strong>${attackRollFinal}</strong>.</p>
<div class="dice-breakdown">${rerollBreakdown}</div>${rerollD30}
<p>Both sides may now react to the new numbers.</p>`
)
// Apply new D30 result on the restart
if (reroll.options?.D30message) {
attackD30message = reroll.options.D30message
attackD30Processed = false
}
// Restart the full comparison so both sides can react to the new roll
mulliganRestart = true
break
+54 -107
View File
@@ -73,20 +73,6 @@
],
"description": "Possible Lethal or Vital Magical Strike or Add D20E to Spell Attack"
},
"arcane_spell_defense": {
"type": "choice",
"choices": [
{
"type": "spell_calamity"
},
{
"type": "bonus_dice",
"dice": "D20E",
"target": "spell_defense"
}
],
"description": "Possible Spell Catastrophe or adds D20E to Spell Defense"
},
"skill_rolls": {
"type": "skill_auto_success",
"description": "Skill Succeeds Regardless of Opposing Roll"
@@ -109,9 +95,19 @@
],
"description": "Possible Flawless or Legendary Defense or Add D20E to Defense"
},
"saving_throws": {
"type": "save_auto_success",
"description": "Saving Throw Succeeds Regardless of Opposing Roll"
"arcane_spell_defense": {
"type": "choice",
"choices": [
{
"type": "spell_calamity"
},
{
"type": "bonus_dice",
"dice": "D20E",
"target": "spell_defense"
}
],
"description": "Possible Spell Catastrophe or adds D20E to Spell Defense"
}
},
"29": {
@@ -135,11 +131,6 @@
"amount": 1,
"description": "Gain 1 Grit"
},
"arcane_spell_defense": {
"type": "gain_grit",
"amount": 1,
"description": "Gain 1 Grit"
},
"skill_rolls": {
"type": "gain_grit",
"amount": 1,
@@ -150,7 +141,7 @@
"amount": 1,
"description": "Gain 1 Grit"
},
"saving_throws": {
"arcane_spell_defense": {
"type": "gain_grit",
"amount": 1,
"description": "Gain 1 Grit"
@@ -184,16 +175,16 @@
"type": "no_lethargy",
"description": "No Spell Lethargy the Aether Approves of Characters Efforts"
},
"ranged_defense": {
"type": "luck_die",
"scope": "combat",
"description": "Granted 1 Luck dice for Use in This Combat Only"
},
"arcane_spell_defense": {
"type": "flash_of_pain",
"duration_dice": "1D6E",
"target": "caster",
"description": "Caster Suffers Severe pain and will be under a flash of pain for 1D6E seconds"
},
"ranged_defense": {
"type": "luck_die",
"scope": "combat",
"description": "Granted 1 Luck dice for Use in This Combat Only"
}
},
"26": {
@@ -208,12 +199,6 @@
"amount": 1,
"target": "skill",
"description": "Add 1 to Skill Roll"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 1,
"target": "save",
"description": "Add 1 to Saving Throw"
}
},
"21": {
@@ -239,12 +224,6 @@
"target": "defender",
"description": "Magical Damage inflicts Flash of pain 1D6E seconds"
},
"arcane_spell_defense": {
"type": "flash_of_pain",
"duration_dice": "1D6E",
"target": "caster",
"description": "Caster Suffers Severe pain and will be under a flash of pain for 1D6E seconds"
},
"skill_rolls": {
"type": "bonus_dice",
"dice": "D6",
@@ -255,11 +234,11 @@
"type": "recover_pain",
"description": "Defender Recovers or ignores any flash of pain"
},
"saving_throws": {
"type": "bonus_dice",
"dice": "D6",
"target": "save",
"description": "Granted D6 (1-6) Saving Throw Modifier for this Saving Throw Attempt"
"arcane_spell_defense": {
"type": "flash_of_pain",
"duration_dice": "1D6E",
"target": "caster",
"description": "Caster Suffers Severe pain and will be under a flash of pain for 1D6E seconds"
}
},
"20": {
@@ -331,23 +310,6 @@
],
"description": "Possible Vicious Application of a Magical Attack or add D12 to attack"
},
"arcane_spell_defense": {
"type": "choice",
"choices": [
{
"type": "special_defense",
"options": [
"perfect_spell"
]
},
{
"type": "bonus_dice",
"dice": "D12",
"target": "spell_defense"
}
],
"description": "Possible 20/20 Spell defense that Saves Against Any Magical Attack Except a Lethal Magical Strike or add D12 to spell defense"
},
"skill_rolls": {
"type": "bonus_flat",
"amount": 20,
@@ -371,11 +333,22 @@
],
"description": "Possible 20/20 defense that avoids Any Attack Except a Lethal Strike or adds D12 to defense"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 20,
"target": "save",
"description": "20 Added to Saving Throw"
"arcane_spell_defense": {
"type": "choice",
"choices": [
{
"type": "special_defense",
"options": [
"perfect_spell"
]
},
{
"type": "bonus_dice",
"dice": "D12",
"target": "spell_defense"
}
],
"description": "Possible 20/20 Spell defense that Saves Against Any Magical Attack Except a Lethal Magical Strike or add D12 to spell defense"
}
},
"15": {
@@ -416,12 +389,6 @@
"punch"
],
"description": "Kick or Punch"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 1,
"target": "save",
"description": "Add 1 to Saving Throw"
}
},
"13": {},
@@ -474,12 +441,6 @@
"punch"
],
"description": "Kick or Punch"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 1,
"target": "save",
"description": "Add 1 to Saving Throw"
}
},
"8": {
@@ -499,10 +460,6 @@
"type": "mulligan",
"description": "Mulligan, Can Re-Roll This Spell Attack"
},
"arcane_spell_defense": {
"type": "mulligan",
"description": "Mulligan, Can Re-Roll This Spell Defense"
},
"skill_rolls": {
"type": "mulligan",
"description": "Mulligan, Can Re-Roll This Skill roll"
@@ -511,9 +468,9 @@
"type": "mulligan",
"description": "Mulligan, Can Choose to Re-Roll This Defense"
},
"saving_throws": {
"arcane_spell_defense": {
"type": "mulligan",
"description": "Mulligan, Can Re-Roll This Saving Throw"
"description": "Mulligan, Can Re-Roll This Spell Defense"
}
},
"7": {
@@ -565,12 +522,6 @@
"punch"
],
"description": "Kick or Punch"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 1,
"target": "save",
"description": "Add 1 to Saving Throw"
}
},
"3": {
@@ -595,17 +546,17 @@
"multiplier": 3,
"description": "Triple Damage on Spell Damage"
},
"arcane_spell_defense": {
"type": "bonus_dice",
"dice": "D12",
"target": "spell_defense",
"description": "D12 Added to Spell Defense Modifier"
},
"ranged_defense": {
"type": "dr_multiplier",
"multiplier": 3,
"includes_shield": true,
"description": "DR Tripled including Shield"
},
"arcane_spell_defense": {
"type": "bonus_dice",
"dice": "D12",
"target": "spell_defense",
"description": "D12 Added to Spell Defense Modifier"
}
},
"2": {
@@ -630,17 +581,17 @@
"multiplier": 2,
"description": "Double Damage on Spell Damage"
},
"arcane_spell_defense": {
"type": "bonus_dice",
"dice": "D6",
"target": "spell_defense",
"description": "D6 Added to Spell Defense Modifier"
},
"ranged_defense": {
"type": "dr_multiplier",
"multiplier": 2,
"includes_shield": true,
"description": "DR Doubled including Shield"
},
"arcane_spell_defense": {
"type": "bonus_dice",
"dice": "D6",
"target": "spell_defense",
"description": "D6 Added to Spell Defense Modifier"
}
},
"1": {
@@ -653,10 +604,6 @@
"type": "spell_calamity",
"description": "A possible spell calamity has occurred"
},
"arcane_spell_defense": {
"type": "spell_calamity",
"description": "Possible Spell Calamity or Catastrophe"
},
"melee_attack": {
"type": "fumble",
"detail": "melee_fumble",
+5 -5
View File
@@ -27,8 +27,7 @@ export default class D30Roll {
RANGED_DEFENSE: "ranged_defense",
ARCANE_SPELL_ATTACK: "arcane_spell_attack",
ARCANE_SPELL_DEFENSE: "arcane_spell_defense",
SKILL_ROLLS: "skill_rolls",
SAVING_THROWS: "saving_throws"
SKILL_ROLLS: "skill_rolls"
}
/**
@@ -134,8 +133,9 @@ export default class D30Roll {
return options.isRanged ? this.ROLL_TYPES.RANGED_DEFENSE : this.ROLL_TYPES.MELEE_DEFENSE
}
// Spell types
if (externalType === "spell-attack" || externalType === "spell" || externalType === "spell-power") {
// Spell/Miracle types
if (externalType === "spell-attack" || externalType === "spell" || externalType === "spell-power"
|| externalType === "miracle-attack" || externalType === "miracle" || externalType === "miracle-power") {
return this.ROLL_TYPES.ARCANE_SPELL_ATTACK
}
@@ -146,7 +146,7 @@ export default class D30Roll {
// Saving throw types
if (externalType === "save") {
return options.isSpellSave ? this.ROLL_TYPES.ARCANE_SPELL_DEFENSE : this.ROLL_TYPES.SAVING_THROWS
return this.ROLL_TYPES.ARCANE_SPELL_DEFENSE
}
// If no match, return null
+26 -5
View File
@@ -555,7 +555,13 @@ export default class LethalFantasyRoll extends Roll {
if (rollContext.favor === "favor") {
rollFavor = new this(baseFormula, options.data, rollData)
await rollFavor.evaluate()
log("Rolling with favor", rollFavor)
log("Favor dice", {
rollBaseTotal: rollBase.total,
rollFavorTotal: rollFavor.total,
rollBaseResults: rollBase.dice.map(d => d.results.map(r => r.result)),
rollFavorResults: rollFavor.dice.map(d => d.results.map(r => r.result)),
baseFormula
})
if (game?.dice3d) {
game.dice3d.showForRoll(rollFavor, game.user, true)
}
@@ -571,6 +577,13 @@ export default class LethalFantasyRoll extends Roll {
if (rollContext.favor === "disfavor") {
rollFavor = new this(baseFormula, options.data, rollData)
await rollFavor.evaluate()
log("Disfavor dice", {
rollBaseTotal: rollBase.total,
rollFavorTotal: rollFavor.total,
rollBaseResults: rollBase.dice.map(d => d.results.map(r => r.result)),
rollFavorResults: rollFavor.dice.map(d => d.results.map(r => r.result)),
baseFormula
})
if (game?.dice3d) {
game.dice3d.showForRoll(rollFavor, game.user, true)
}
@@ -616,8 +629,10 @@ export default class LethalFantasyRoll extends Roll {
let singleDice = `1D${maxValue}`
for (let i = 0; i < rollBase.dice.length; i++) {
for (let j = 0; j < rollBase.dice[i].results.length; j++) {
let diceResult = rollBase.dice[i].results[j].result
const dieResults = rollBase.dice[i].results
const resultCount = dieResults.length
for (let j = 0; j < resultCount; j++) {
let diceResult = dieResults[j].result
diceResults.push({ dice: `${singleDice.toUpperCase()}`, value: diceResult })
diceSum += diceResult
if (hasMaxValue) {
@@ -626,6 +641,8 @@ export default class LethalFantasyRoll extends Roll {
diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${singleDice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1)
// Add to DieTerm results so DSN/Foundry display shows explosion dice
dieResults.push({ result: diceResult, active: true })
}
}
}
@@ -1214,6 +1231,7 @@ export default class LethalFantasyRoll extends Roll {
diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1)
rollBase.dice[0].results.push({ result: diceResult, active: true })
}
if (fullModifier !== 0) {
diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total })
@@ -1376,6 +1394,7 @@ export default class LethalFantasyRoll extends Roll {
diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1)
rollBase.dice[0].results.push({ result: diceResult, active: true })
}
if (fullModifier !== 0) {
@@ -1486,7 +1505,7 @@ export default class LethalFantasyRoll extends Roll {
if (this.type === "weapon-attack" && this.rollTarget?.weapon) {
const weapon = this.rollTarget.weapon
weaponDamageOptions = {
weaponId: weapon.id,
weaponId: weapon._id || weapon.id,
weaponName: weapon.name,
damageM: weapon.system?.damage?.damageM
}
@@ -1588,7 +1607,8 @@ export default class LethalFantasyRoll extends Roll {
let diceSum = 0
for (const term of roll.dice) {
const singleDice = `1D${term.faces}`
for (const r of term.results) {
const termResults = Array.from(term.results)
for (const r of termResults) {
let diceResult = r.result
diceResults.push({ dice: singleDice.toUpperCase(), value: diceResult })
diceSum += diceResult
@@ -1599,6 +1619,7 @@ export default class LethalFantasyRoll extends Roll {
diceResult = xr.dice?.[0]?.results?.[0]?.result ?? (term.faces - 1)
diceResults.push({ dice: `${singleDice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1)
term.results.push({ result: diceResult, active: true })
}
}
}
-1
View File
@@ -1128,7 +1128,6 @@ export default class LethalFantasyUtils {
return await RollClass.prompt({
...foundry.utils.duplicate(rerollContext),
rollContext: foundry.utils.duplicate(rerollContext.rollContext || {}),
forceNoD30: true,
hasTarget: false,
target: false
})