Compare commits

...

36 Commits

Author SHA1 Message Date
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
uberwald 60b351f50d Fix spell save/defense again
Release Creation / build (release) Successful in 45s
2026-06-13 21:06:18 +02:00
uberwald ace726a1fc fix: use try/finally for spellDefense cleanup instead of delete
Release Creation / build (release) Successful in 47s
delete on game.lethalFantasy.spellDefense was breaking roll flow
on Foundry's proxied game object.  Use try/finally with
assignment to false instead, which is safe on any object type.

Initialize saveSpell local var from one-shot flag so D30
chart lookup correctly uses arcane_spell_defense for spell saves
without requiring user to click the pre-checked checkbox.
2026-06-13 16:51:52 +02:00
uberwald 67499bc199 fix: add missing arcane_spell_defense entry for D30=1
Release Creation / build (release) Successful in 49s
User spec lists D30=1 as 'Possible Spell Calamity or Catastrophe'
for Arcane Spell Defense but it was missing from the table.
2026-06-13 16:29:54 +02:00
uberwald 7eae95cbbd fix: use arcane spell defense D30 chart for spell saves
Release Creation / build (release) Successful in 43s
Bug: saveSpell local var initialized to false (line 142), while
dialog checkbox was pre-checked via dialogContext.saveSpell =
game.lethalFantasy.spellDefense.  If user didn't click the checkbox,
D30 call used SAVING_THROWS chart instead of ARCANE_SPELL_DEFENSE.

Also: game.lethalFantasy.spellDefense was set true before spell
defense rolls but never cleared, leaking to subsequent non-spell saves.

Fix: initialize saveSpell from the one-shot flag and delete it
immediately.  Dialog context now uses the local saveSpell variable
instead of re-reading the deleted flag.
2026-06-13 15:22:34 +02:00
uberwald 1b53bf9152 fix: resolve hud actor from hud.token not render context param
Release Creation / build (release) Successful in 41s
V2 renderTokenHUD passes (hud, html, data) where data is a render
context object — not a TokenDocument.  data.actor was undefined,
and data?.token?.actor was also undefined.  Use hud.token.actor
(or hud.object.actor) instead, which is the real PlaceableObject
with proper actor resolution.

Also fix html.find() → html.querySelector() for V2 HTMLElement.
2026-06-12 19:01:54 +02:00
uberwald 2570bf707e fix: prevent duplicate cross-client defense dialog, clear bleed on heal
- Only send attackBoosted socket when attackerHandledBonus || attackerHasNonGMOwner
  (GM→player: hook handles it, no socket needed; PC→PC: socket needed)
- Clear bleeding wounds when HP restored via token HUD heal buttons
2026-06-12 17:23:39 +02:00
uberwald cbeaaeec99 Final fixes and code review checks
Release Creation / build (release) Successful in 39s
2026-06-12 08:19:42 +02:00
uberwald 37badf2619 fix: attack/defense cross-client reaction flow
- C1: Stop D30 auto-roll on non-primary clients (caused divergence)
- C2: defenderOwner fallback to GM for monster defenders
- C3: Fix tie outcome in handleAttackBoosted (>= not >)
- C5: Convert handleAttackBoosted to while-loop (multi-reaction)
- C4/C6: shouldCreateMessage cross-client guard
- M2: Coordinate main flow defender dialog vs socket handler
- M3: Fresh grit/luck reads each socket handler iteration
- M4: Include defenseD30message in socket payload + re-process
- M5: Communicate attackerHandledBonus in socket payload
- i18n: Add missing COMBAT.* keys, fix weapon.hbs label localize
- d30_results_tables: Fix string typo
2026-06-12 02:51:59 +02:00
uberwald 5839616863 chore: add pushLDBtoYML/pullYMLtoLDB scripts, gitignore packs_src 2026-06-12 01:56:19 +02:00
uberwald 89298490ef fix: pre-check 'Save against spell' checkbox in template when saveSpell is true 2026-06-12 01:56:03 +02:00
uberwald bb42de19bd REmove unused file 2026-06-11 23:05:55 +02:00
uberwald 53f9c33419 chore: gitignore LevelDB internal files, stop tracking auto-generated LDB bookkeeping
Release Creation / build (release) Successful in 46s
2026-06-11 23:00:45 +02:00
uberwald 06eba5f835 fix: show spell tier dialog on character sheet cast; duplicate rollTarget to prevent Item mutation 2026-06-11 22:56:54 +02:00
uberwald 46fa2d15a3 fix: allow defender to react when attacker boosts past defense via cross-client socket 2026-06-11 22:41:54 +02:00
uberwald 8aae7bada0 fix: pre-check 'Save against spell' checkbox when defense originates from spell attack 2026-06-11 22:17:37 +02:00
uberwald ceb62bca3f fix: add missing class lethal-luck-grit-hud to template so JS selector matches 2026-06-11 22:03:56 +02:00
uberwald 110ac65ba5 fix: replace hardcoded French bleeding notifications with i18n keys 2026-06-11 21:50:19 +02:00
uberwald 9b75fd4d96 feat: combat-tracker-driven bleeding (HP loss per wound per round) 2026-06-11 21:49:35 +02:00
uberwald 141d6048e0 Fix triple damage issue 2026-06-11 21:32:26 +02:00
uberwald ea7acf6bf8 Fix hp < 0 and D30 with D20 bonus roll 2026-06-11 20:48:46 +02:00
uberwald c20750caa7 Minor fixes regarding rolls and chat messages
Release Creation / build (release) Successful in 48s
2026-06-10 20:17:45 +02:00
uberwald ce630feb51 feat: D30 combat effects, spell tiers, small damage removal, token HUD luck/grit
- Replace Knockback with Internal Injury on D30 (5, 10, 15); remove Shield Bash from D30 counter-attacks
- Eliminate small weapon damage: keep only medium damage labelled Damage in sheets, rolls, and chat
- D30 bonus dice (20, 27, 30) auto-resolved before grit/luck/shield decisions; choice dialogs for special strikes
- D30 combat effects: bleeding wounds, damage ×2/×3 before DR, DR ×2/×3 with component picker dialog
- Add hp.wounds to monster schema for bleeding support
- Show Save against spell? checkbox for all save rolls (not just magic users)
- Fix mulligan restart: persistent D30 process flags prevent double-application and allow both sides to react
- For Dice So Nice, show main roll animation before explosion dice for correct ordering
- Spell tier selection: force Standard/Overpowered choice at cast time, tier-specific aether cost, only chosen damage button shown
- Add +1/−1 luck and grit controls to Token HUD
- Fix inconsistent indentation, remove duplicate i18n key, remove unused includesShield return
2026-06-10 07:53:51 +02:00
uberwald b35b684d50 NEgative values for HP and weapon bonuses 2026-06-06 16:11:36 +02:00
uberwald f6fb0b68b8 Fix spell rolls again
Release Creation / build (release) Successful in 47s
2026-05-25 20:41:00 +02:00
uberwald e45edd60c4 FIx spell order and dual rollll for spell damages
Release Creation / build (release) Successful in 1m6s
2026-05-25 12:29:39 +02:00
uberwald d389a85a9f Fix ranged attacks again
Release Creation / build (release) Successful in 43s
2026-05-24 09:42:07 +02:00
uberwald c217490a5b Fix ranged attacks again
Release Creation / build (release) Has been cancelled
2026-05-24 09:41:06 +02:00
uberwald 38eb1a8d3d Add ranged actions for monsters
Release Creation / build (release) Successful in 54s
2026-05-23 19:10:10 +02:00
uberwald 4724cdf2bb VArious fixes for rolls and ranged attacks
Release Creation / build (release) Successful in 44s
2026-05-23 09:08:16 +02:00
uberwald 6d06c8ddad Various fixes for spell and ranged attacks 2026-05-23 00:21:05 +02:00
uberwald 2770774aa3 Various fixes for spell and ranged attacks 2026-05-23 00:11:58 +02:00
66 changed files with 2225 additions and 846 deletions
+1
View File
@@ -0,0 +1 @@
packs/** filter=lfs diff=lfs merge=lfs -text
+2 -2
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: "13"
compatibility-minimum: "14"
compatibility-verified: "14"
+11
View File
@@ -9,3 +9,14 @@ node_modules/
.history
.github/
# LevelDB internals (auto-generated, churn on every open)
packs-system/**/*.log
packs-system/**/LOG
packs-system/**/LOG.old
packs-system/**/CURRENT
packs-system/**/LOCK
packs-system/**/MANIFEST-*
# YAML source for pack round-trip
packs_src/
+6
View File
@@ -0,0 +1,6 @@
{
"cSpell.words": [
"biodata",
"LETHALFANTASY"
]
}
+105
View File
@@ -0,0 +1,105 @@
# Lethal Fantasy FoundryVTT System — Session Context
## Current Goal
Fix Grit/Luck defense reaction dialog UX (stacking dialogs, multiple clicks, revert on close) and cross-client sync of defense bonuses.
## Accomplished
### Pass 1 — Critical Issues
- **Telemetry removed**: `ClassCounter`, `registerWorldCount`, orphaned `worldKey` setting deleted from system.json
- **globalThis side effects**: `globalThis.SYSTEM`, `globalThis.pendingDefenses` moved from top-level to `init` hook
- **console.log → log()**: All runtime console.log replaced with `log()` helper guarded by `lethalFantasy.debug` setting
- **Stale Tenebris refs**: `macros.mjs``TENEBRIS.Label.jet``LETHALFANTASY.Label.jet`, `TENEBRIS.Manager.*``LETHALFANTASY.Label.*`, `tenebris.macro` flag → `lethalFantasy.macro`
### Pass 2 — V1/V2 Mixing, Fire-and-Forget
- **V1 sheet registrations removed**: `foundry.appv1.sheets.*` in system.json
- **V1 `activateListeners`/jQuery**: removed dead `defaultOptions`, V1 tab code from `combat.mjs`
- **V2 API paths**: `FilePicker` → V2, `TextEditor.getDragEventData` → V2, `item.sheet.render(true)``render({force:true})`, `super._onRender()``super._onRender(context, options)`, `token._id``token.id`
- **Fire-and-forget Promises**: All `actor.update()`, `ChatMessage.create()`, `prepareRoll()`, `prepareMonsterRoll()`, socket handler calls now awaited
- **Misnamed class**: `LethalFantasySkill``LethalFantasyWeapon`; added missing `WEAPON_TYPE` import; fixed `weaponCategory`
### Pass 3 — Code Review Fixes
- **Duplicated dialogs**: Per-element `.rollable`/`.wound-data` bindings moved to `_onRender` (V2 destroys/recreates DOM each render); `_activateListeners` reverted
- **renderChatMessage reverted**: V2 hook `renderChatMessage` passes jQuery html, `querySelectorAll` fails; kept `renderChatMessageHTML`
- **Roll actions broken**: Fixed `async` base-actor-sheet methods; `_onRender` bindings for rollable elements restored
- **Token HUD guard**: `html.querySelector()``html.find().length` (html is jQuery object)
- **All review awaits confirmed**: `showDefenseRequest`/`socket` handlers all awaited
## Defense Dialog Investigation — Status
### Symptom (user process)
1. Monster (GM) attacks player — hits
2. Player uses Grit/Luck to boost defense
3. Defense now beats attack — reports new result
4. Dialog **stays open** — Grit/Luck/bonus dice options still visible
5. Closing dialog (Continue or X) causes "rolls vanish" — reverts to original result
### Root Cause Found — Duplicate cross-client processing (FIXED)
When monster (GM) attacks player, the `createChatMessage` hook fires on **both** clients:
```
Player's client: GM's client:
defense msg created defense msg synced
↓ ↓
hook fires (line 557) hook fires (line 557)
isPrimaryController(defender)=true isPrimaryController(defender)=false
↓ ↓
Defense dialog A shows Defense dialog skipped
Player spends Grit Cross-client code (line 1009):
defenseRoll=10→16 isPrimaryController(attacker)=true
While loop exits defenderOwner=player (≠GM)
Comparison: "miss" ↓
**Sends attackBoosted with ORIGINAL
defenseRoll=10 (stale!)**
Player receives socket → handleAttackBoosted
→ Defense dialog B shows with OLD values
→ When closed, comparison: "hit" (overwrites!)
```
Player sees **two** dialogs (A then B). Dialog B uses unboosted values, so closing/ignoring it produces a stale "hit" result that overwrites the correct "miss."
### Fix
`lethal-fantasy.mjs:1016` — only send `attackBoosted` socket when `attackerHandledBonus || attackerHasNonGMOwner`. Guards against stale-socket overwrite for GM→player combat (where hook-based processing works without socket), while preserving socket delegation for PC→PC cross-client (where `attackerIsCrossClient` suppresses the hook-based processing on the defender's client).
Before:
```js
if (defenderOwner && defenderOwner.id !== game.user.id) {
game.socket.emit(`system.${SYSTEM.id}`, { type: "attackBoosted", ... })
return
}
```
After:
```js
if (defenderOwner && defenderOwner.id !== game.user.id) {
if (attackerHandledBonus || attackerHasNonGMOwner) {
game.socket.emit(`system.${SYSTEM.id}`, { type: "attackBoosted", ... })
}
return
}
```
### Same-Client Path
Code pattern is identical between attack and defense dialogs — both use `await DialogV2.wait({rejectClose:false})` in a while loop. Same-client defense works correctly because no duplicate socket messages arrive.
### Other Findings
- `offerGritLuckBonus` (`utils.mjs:1121`) is dead code — never called
- `promptCombatBonusDie` (`utils.mjs:975`) is correct — DialogV2 resolves to callback return value, not `action`
- Cross-client `handleAttackBoosted` (`utils.mjs:291`) still uses `else if` chain without `continue` — functionally correct but differs from same-client pattern
### Code Paths
| Flow | File | Line |
|------|------|------|
| Same-client attack | `lethal-fantasy.mjs` | 918-1004 |
| Same-client defense | `lethal-fantasy.mjs` | 697-870 |
| Cross-client defense | `module/utils.mjs` | 291-445 |
| Cross-client socket guard | `lethal-fantasy.mjs` | 1006-1037 |
| Attack Grit offer | `module/utils.mjs` | 1210-1290 |
### Key Files
- `lethal-fantasy.mjs` — Main system hooks, same-client attack/defense reactions
- `module/utils.mjs` — Cross-client defense flow, bonus dialogs, compareAttackDefense
- `module/documents/actor.mjs``prepareRoll()` entry point
- `module/documents/roll.mjs` — Roll resolution pipeline
+59 -11
View File
@@ -529,16 +529,16 @@ i.lethalfantasy {
}
.lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .armor-hp {
margin-right: 4px;
min-width: 10rem;
max-width: 10rem;
min-width: 11rem;
max-width: 11rem;
}
.lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .armor-hp .name {
min-width: 6rem;
max-width: 6rem;
}
.lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .armor-hp .input {
min-width: 2.5rem;
max-width: 2.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
}
.lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .granted {
min-width: 8rem;
@@ -3280,16 +3280,10 @@ i.lethalfantasy {
.message.lethalfantasy.dice-roll .damage-buttons .damage-buttons-grid,
.message.fvtt-lethal-fantasy.dice-roll .damage-buttons .damage-buttons-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.lethalfantasy.dice-roll .damage-buttons .damage-buttons-grid.monster-damage,
.fvtt-lethal-fantasy.dice-roll .damage-buttons .damage-buttons-grid.monster-damage,
.message.lethalfantasy.dice-roll .damage-buttons .damage-buttons-grid.monster-damage,
.message.fvtt-lethal-fantasy.dice-roll .damage-buttons .damage-buttons-grid.monster-damage {
grid-template-columns: 1fr;
max-width: 280px;
margin: 0 auto;
gap: 8px;
}
.lethalfantasy.dice-roll .damage-buttons .damage-buttons-grid .damage-roll-btn,
.fvtt-lethal-fantasy.dice-roll .damage-buttons .damage-buttons-grid .damage-roll-btn,
@@ -3549,6 +3543,7 @@ i.lethalfantasy {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
justify-items: center;
}
.lethalfantasy.dice-roll .d30-message,
.fvtt-lethal-fantasy.dice-roll .d30-message,
@@ -4139,6 +4134,59 @@ i.lethalfantasy {
padding-left: 8px;
font-size: 0.9rem;
}
/* Luck/Grit Styles */
#token-hud .luck-grit-wrap {
position: absolute;
left: 75px;
display: none;
top: 50%;
width: 80px;
text-align: start;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(139, 69, 19, 0.5);
border-radius: 4px;
padding: 4px 6px;
transform: translate(-100%, -50%);
}
#token-hud .luck-grit-hud-active {
display: block;
}
#token-hud .luck-grit-hud-disabled {
display: none;
}
#token-hud .luck-grit-row {
display: flex;
align-items: center;
gap: 4px;
margin: 2px 0;
}
#token-hud .luck-grit-label {
flex: 1;
color: #c9b896;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
#token-hud .luck-grit-btn {
width: 28px;
height: 22px;
padding: 0;
background: rgba(139, 69, 19, 0.25);
border: 1px solid rgba(139, 69, 19, 0.4);
border-radius: 3px;
color: #d4c5a9;
font-size: 12px;
font-weight: 700;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
#token-hud .luck-grit-btn:hover {
background: rgba(139, 69, 19, 0.5);
border-color: rgba(139, 69, 19, 0.7);
}
/* -------------------------------------------------- */
/* Dice Tray — injected into the Foundry chat sidebar */
/* -------------------------------------------------- */
+36 -4
View File
@@ -1,8 +1,32 @@
{
"COMBAT": {
"Begin": "Begin Combat",
"Create": "Create Encounter",
"Delete": "Delete Encounter",
"Encounter": "Encounter",
"EncounterNext": "Next Encounter",
"EncounterPrevious": "Previous Encounter",
"End": "End Combat",
"InitiativeReset": "Reset Initiative",
"InitiativeRoll": "Roll Initiative",
"InitiativeScore": "Initiative Score",
"NavLabel": "Combat Tracker Navigation",
"None": "None",
"NotStarted": "Not Started",
"PanToCombatant": "Pan to Combatant",
"PingCombatant": "Ping Combatant",
"RollAll": "Roll All",
"RollNPC": "Roll NPCs",
"Round": "Second {round}",
"RoundNext": "Next second",
"RoundPrev": "Previous second",
"Rounds": "Seconds",
"RoundNext": "Next second"
"Settings": "Combat Settings",
"ToggleDead": "Toggle Dead",
"ToggleVis": "Toggle Visible",
"TurnEnd": "End Turn",
"TurnNext": "Next Turn",
"TurnPrev": "Previous Turn"
},
"LETHALFANTASY": {
"Armor": {
@@ -471,6 +495,8 @@
"range": "Range",
"rangeDefenseDialog": "Ranged defense dialog",
"rangeDefenseRoll": "Ranged defense roll",
"rangeAttackDialog": "Ranged attack dialog",
"rangeAttackRoll": "Ranged attack roll",
"rangedAttackDefense": "Ranged attack defense",
"resource": "Resource",
"resources": "Resources",
@@ -503,8 +529,6 @@
"monster-defense": "Monster defense",
"weapons": "Weapons",
"wis": "WIS",
"weapon-damage-medium": "Weapon damage medium",
"weapon-damage-small": "Weapon damage small",
"combatProgressionStart": "Combat start threshold",
"miracle": "Miracle",
"titleStandard": "Standard Roll",
@@ -624,7 +648,9 @@
"messageLethargyKO": "{spellName} : Lethargy still ongoing ... ( dice result : {roll} )",
"messageProgressionKO": "{name} can't attack this second.",
"messageProgressionOKMonster": "{name} can attack this second with {weapon}.",
"messageProgressionKOMonster": "{name} can't attack this second (dice result {roll})."
"messageProgressionKOMonster": "{name} can't attack this second (dice result {roll}).",
"bleedingCombatEnd": "Bleeding active out of combat: {names}",
"bleedingCombatStart": "Bleeding still active on: {names}"
},
"Opponent": {
"FIELDS": {}
@@ -826,6 +852,12 @@
"cost": {
"label": "Cost"
},
"costOverpowered": {
"label": "Cost (Overpowered)"
},
"costOverpowered2": {
"label": "Cost (Overpowered 2)"
},
"description": {
"label": "Description"
},
+699 -334
View File
File diff suppressed because it is too large Load Diff
+13 -41
View File
@@ -24,30 +24,29 @@ export class LethalFantasyCombatTracker extends foundry.applications.sidebar.tab
async _prepareContext(options) {
let data = await super._prepareContext(options);
console?.log("Combat Tracker Data", data);
log("Combat Tracker Data", data);
/*for (let u of data.turns) {
let c = game.combat.combatants.get(u.id);
u.progressionCount = c.system.progressionCount
u.isMonster = c.actor.type === "monster"
}
console.log("Combat Data", data);*/
log("Combat Data", data);*/
return data;
}
static #initiativePlus(ev) {
static async #initiativePlus(ev) {
ev.preventDefault();
let cId = ev.target.closest(".combatant").dataset.combatantId;
let c = game.combat.combatants.get(cId);
c.update({ 'initiative': c.initiative + 1 });
console.log("Initiative Plus");
await c.update({ 'initiative': c.initiative + 1 });
}
static #initiativeMinus(ev) {
static async #initiativeMinus(ev) {
ev.preventDefault();
let cId = ev.target.closest(".combatant").dataset.combatantId;
let c = game.combat.combatants.get(cId);
let newInit = Math.max(c.initiative - 1, 0);
c.update({ 'initiative': newInit });
await c.update({ 'initiative': newInit });
}
/**
@@ -58,33 +57,6 @@ export class LethalFantasyCombatTracker extends foundry.applications.sidebar.tab
ev.preventDefault();
await game.combat.rollMonsterProgression();
}
activateListeners(html) {
super.activateListeners(html);
// Display Combat settings
html.find(".initiative-plus").click(ev => {
ev.preventDefault();
let cId = ev.currentTarget.closest(".combatant").dataset.combatantId;
let c = game.combat.combatants.get(cId);
c.update({ 'initiative': c.initiative + 1 });
});
html.find(".initiative-minus").click(ev => {
ev.preventDefault();
let cId = ev.currentTarget.closest(".combatant").dataset.combatantId;
let c = game.combat.combatants.get(cId);
c.update({ 'initiative': c.initiative - 1 });
console.log("Initiative Minus");
});
}
/* -------------------------------------------- */
static get defaultOptions() {
let path = "systems/fvtt-lethal-fantasy/templates/combat-tracker.hbs";
return foundry.utils.mergeObject(super.defaultOptions, {
template: path,
});
}
}
export class LethalFantasyCombat extends Combat {
@@ -94,7 +66,7 @@ export class LethalFantasyCombat extends Combat {
* @returns {Combatant[]}
*/
setupTurns() {
console?.log("Setup Turns....");
log("Setup Turns....");
this.turns ||= [];
// Determine the turn order and the current turn
@@ -164,19 +136,19 @@ export class LethalFantasyCombat extends Combat {
}
}
resetProgression(cId) {
async resetProgression(cId) {
let c = this.combatants.get(cId);
c.update({ 'system.progressionCount': 0 });
await c.update({ 'system.progressionCount': 0 });
}
setCasting(cId) {
async setCasting(cId) {
let c = this.combatants.get(cId);
c.setFlag(SYSTEM.id, "casting", true);
await c.setFlag(SYSTEM.id, "casting", true);
}
resetCasting(cId) {
async resetCasting(cId) {
let c = this.combatants.get(cId);
c.setFlag(SYSTEM.id, "casting", false);
await c.setFlag(SYSTEM.id, "casting", false);
}
isCasting(cId) {
@@ -78,7 +78,6 @@ export default class LethalFantasyActorSheet extends HandlebarsApplicationMixin(
/** @override */
_onRender(context, options) {
this.#dragDrop.forEach((d) => d.bind(this.element))
// Add listeners to rollable elements
const rollables = this.element.querySelectorAll(".rollable")
rollables.forEach((d) => d.addEventListener("click", this._onRoll.bind(this)))
}
@@ -234,12 +233,12 @@ export default class LethalFantasyActorSheet extends HandlebarsApplicationMixin(
const attr = target.dataset.edit
const current = foundry.utils.getProperty(this.document, attr)
const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {}
const fp = new FilePicker({
const fp = new foundry.applications.ux.FilePicker.implementation({
current,
type: "image",
redirectToRoot: img ? [img] : [],
callback: (path) => {
this.document.update({ [attr]: path })
callback: async (path) => {
await this.document.update({ [attr]: path })
},
top: this.position.top + 40,
left: this.position.left + 10,
@@ -261,7 +260,7 @@ export default class LethalFantasyActorSheet extends HandlebarsApplicationMixin(
item = await fromUuid(uuid)
if (!item) item = this.document.items.get(id)
if (!item) return
item.sheet.render(true)
item.sheet.render({ force: true })
}
/**
@@ -284,8 +283,8 @@ export default class LethalFantasyActorSheet extends HandlebarsApplicationMixin(
* @private
* @static
*/
static #onCreateSpell(event, target) {
const item = this.document.createEmbeddedDocuments("Item", [{ name: "Nouveau sortilège", type: "spell" }])
static async #onCreateSpell(event, target) {
await this.document.createEmbeddedDocuments("Item", [{ name: "Nouveau sortilège", type: "spell" }])
}
// #endregion
@@ -177,12 +177,12 @@ export default class LethalFantasyItemSheet extends HandlebarsApplicationMixin(f
const attr = target.dataset.edit
const current = foundry.utils.getProperty(this.document, attr)
const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {}
const fp = new FilePicker({
const fp = new foundry.applications.ux.FilePicker.implementation({
current,
type: "image",
redirectToRoot: img ? [img] : [],
callback: (path) => {
this.document.update({ [attr]: path })
callback: async (path) => {
await this.document.update({ [attr]: path })
},
top: this.position.top + 40,
left: this.position.left + 10,
+22 -30
View File
@@ -180,44 +180,44 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
await this.document.system.rollInitiative()
}
static #onArmorHitPointsPlus(event, target) {
static async #onArmorHitPointsPlus(event, target) {
let armorHP = this.actor.system.combat.armorHitPoints
armorHP += 1
this.actor.update({ "system.combat.armorHitPoints": armorHP })
await this.actor.update({ "system.combat.armorHitPoints": armorHP })
}
static #onArmorHitPointsMinus(event, target) {
static async #onArmorHitPointsMinus(event, target) {
let armorHP = this.actor.system.combat.armorHitPoints
armorHP -= 1
this.actor.update({ "system.combat.armorHitPoints": Math.max(armorHP, 0) })
await this.actor.update({ "system.combat.armorHitPoints": Math.max(armorHP, 0) })
}
static #onDivinityPointsPlus(event, target) {
static async #onDivinityPointsPlus(event, target) {
let points = this.actor.system.divinityPoints.value
points += 1
points = Math.min(points, this.actor.system.divinityPoints.max)
this.actor.update({ "system.divinityPoints.value": points })
await this.actor.update({ "system.divinityPoints.value": points })
}
static #onDivinityPointsMinus(event, target) {
static async #onDivinityPointsMinus(event, target) {
let points = this.actor.system.divinityPoints.value
points -= 1
points = Math.max(points, 0)
this.actor.update({ "system.divinityPoints.value": points })
await this.actor.update({ "system.divinityPoints.value": points })
}
static #onAetherPointsPlus(event, target) {
static async #onAetherPointsPlus(event, target) {
let points = this.actor.system.aetherPoints.value
points += 1
points = Math.min(points, this.actor.system.aetherPoints.max)
this.actor.update({ "system.aetherPoints.value": points })
await this.actor.update({ "system.aetherPoints.value": points })
}
static #onAetherPointsMinus(event, target) {
static async #onAetherPointsMinus(event, target) {
let points = this.actor.system.aetherPoints.value
points -= 1
points = Math.max(points, 0)
this.actor.update({ "system.aetherPoints.value": points })
await this.actor.update({ "system.aetherPoints.value": points })
}
/**
@@ -255,25 +255,28 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
buttons: [
{
action: "noDR",
type: "button",
label: game.i18n.localize("LETHALFANTASY.Combat.spellNoDR"),
icon: "fa-solid fa-wand-magic-sparkles",
callback: () => 0
},
{
action: "applyDR",
type: "button",
label: game.i18n.localize("LETHALFANTASY.Combat.spellApplyDR"),
icon: "fa-solid fa-shield",
callback: (event, button) => Number(button.form?.elements?.manualDr?.value) || 0
},
{
action: "cancel",
type: "button",
label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo"),
callback: () => null
callback: () => "cancel"
}
],
rejectClose: false
})
if (manualDR === null) return
if (manualDR === null || manualDR === "cancel") return
const rollOpts = {
type: "spell-damage",
@@ -286,27 +289,16 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
actorName: this.actor.name,
actorImage: this.actor.img
}
const roll = new LethalFantasyRoll(formula, {}, rollOpts)
await roll.evaluate()
const diceResults = []
for (const term of roll.dice) {
for (const r of term.results) {
diceResults.push({ dice: `1D${term.faces}`, value: r.result })
}
}
roll.options.diceResults = diceResults
roll.options.rollTotal = roll.total
await roll.toMessage()
await LethalFantasyRoll.rollSpellDamageToMessage(formula, rollOpts)
}
static #onCreateEquipment(event, target) {
}
_onRender(context, options) {
// Inputs with class `item-quantity`
const woundDescription = this.element.querySelectorAll('.wound-data')
for (const input of woundDescription) {
input.addEventListener("change", (e) => {
input.addEventListener("change", async (e) => {
e.preventDefault();
e.stopImmediatePropagation();
const newValue = e.currentTarget.value
@@ -314,11 +306,11 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
const fieldName = e.currentTarget.dataset.name
let tab = foundry.utils.duplicate(this.actor.system.hp.wounds)
tab[index][fieldName] = newValue
console.log(tab, index, fieldName, newValue)
this.actor.update({ "system.hp.wounds": tab });
log(tab, index, fieldName, newValue)
await this.actor.update({ "system.hp.wounds": tab });
})
}
super._onRender();
super._onRender(context, options);
}
+1 -1
View File
@@ -91,7 +91,7 @@ export default class LethalFantasyMonsterSheet extends LethalFantasyActorSheet {
*/
async _onDrop(event) {
if (!this.isEditable || !this.isEditMode) return
const data = TextEditor.getDragEventData(event)
const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event)
// Handle different data types
switch (data.type) {
+82 -88
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"
@@ -108,6 +94,20 @@
}
],
"description": "Possible Flawless or Legendary Defense or Add D20E to Defense"
},
"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": {
@@ -131,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,
@@ -145,6 +140,11 @@
"type": "gain_grit",
"amount": 1,
"description": "Gain 1 Grit"
},
"arcane_spell_defense": {
"type": "gain_grit",
"amount": 1,
"description": "Gain 1 Grit"
}
},
"28": {
@@ -175,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": {
@@ -224,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",
@@ -239,6 +233,12 @@
"ranged_defense": {
"type": "recover_pain",
"description": "Defender Recovers or ignores any flash of pain"
},
"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": {
@@ -310,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 defense"
},
"skill_rolls": {
"type": "bonus_flat",
"amount": 20,
@@ -349,6 +332,23 @@
}
],
"description": "Possible 20/20 defense that avoids Any Attack Except a Lethal Strike or adds D12 to defense"
},
"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": {
@@ -359,10 +359,10 @@
"type": "bleed"
},
{
"type": "knockback"
"type": "internal_injury"
}
],
"description": "Bleed, Knock-back on Hit"
"description": "Bleed, Internal Injury on Hit"
},
"ranged_attack": {
"type": "bleed",
@@ -372,10 +372,9 @@
"type": "counter_attack",
"options": [
"kick",
"punch",
"shield_bash"
"punch"
],
"description": "Kick, Punch or Shield Bash"
"description": "Kick or Punch"
},
"skill_rolls": {
"type": "bonus_flat",
@@ -387,10 +386,9 @@
"type": "counter_attack",
"options": [
"kick",
"punch",
"shield_bash"
"punch"
],
"description": "Kick, Punch or Shield Bash"
"description": "Kick or Punch"
}
},
"13": {},
@@ -398,7 +396,7 @@
"melee_attack": {
"type": "flurry",
"condition": "hit_or_miss",
"description": "Flurry Attack or Hit to Miss"
"description": "Flurry Attack on Hit or Miss"
},
"ranged_attack": {
"type": "double_damage_dice",
@@ -413,10 +411,10 @@
"type": "bleed"
},
{
"type": "knockback"
"type": "internal_injury"
}
],
"description": "Bleed, Knock-back on Hit"
"description": "Bleed, Internal Injury on Hit"
},
"ranged_attack": {
"type": "bleed",
@@ -426,10 +424,9 @@
"type": "counter_attack",
"options": [
"kick",
"punch",
"shield_bash"
"punch"
],
"description": "Kick, Punch or Shield Bash"
"description": "Kick or Punch"
},
"skill_rolls": {
"type": "bonus_flat",
@@ -441,10 +438,9 @@
"type": "counter_attack",
"options": [
"kick",
"punch",
"shield_bash"
"punch"
],
"description": "Kick, Punch or Shield Bash"
"description": "Kick or Punch"
}
},
"8": {
@@ -464,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"
@@ -475,6 +467,10 @@
"ranged_defense": {
"type": "mulligan",
"description": "Mulligan, Can Choose to Re-Roll This Defense"
},
"arcane_spell_defense": {
"type": "mulligan",
"description": "Mulligan, Can Re-Roll This Spell Defense"
}
},
"7": {
@@ -496,10 +492,10 @@
"type": "bleed"
},
{
"type": "knockback"
"type": "internal_injury"
}
],
"description": "Bleed, Knock-back on Hit"
"description": "Bleed, Internal Injury on Hit"
},
"ranged_attack": {
"type": "bleed",
@@ -509,10 +505,9 @@
"type": "counter_attack",
"options": [
"kick",
"punch",
"shield_bash"
"punch"
],
"description": "Kick, Punch, or Shield Bash"
"description": "Kick or Punch"
},
"skill_rolls": {
"type": "bonus_flat",
@@ -524,10 +519,9 @@
"type": "counter_attack",
"options": [
"kick",
"punch",
"shield_bash"
"punch"
],
"description": "Kick, Punch, or Shield Bash"
"description": "Kick or Punch"
}
},
"3": {
@@ -552,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": {
@@ -587,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": {
+9 -1
View File
@@ -85,11 +85,18 @@ export const RANGE_CHOICES = {
"pointblank": { label: "Point Blank (Special)", value: "pointblank" },
"short": { label: "Short (+0)", value: "0" },
"medium": { label: "Medium (Red +5)", value: "+5" },
"long": { label: "Long (Purle +7)", value: "+7" },
"long": { label: "Long (Purple +7)", value: "+7" },
"extreme": { label: "Extreme (Grey +9)", value: "+9" },
"beyondskill": { label: "Beyond Skill (Blue +11)", value: "beyondskill" }
}
export const ATTACKER_MOVEMENT_CHOICES = {
"none": { label: "None / Stationary (D20E Favor)", favor: true, value: "2D20kh" },
"walk": { label: "Walk (D20E)", value: "D20" },
"incombat": { label: "In Combat (D20E)", value: "D20" },
"run": { label: "Jog/Run/Sprint (D20E Disfavor)", disfavor: true, value: "2D20kl" }
}
export const ATTACKER_AIM_CHOICES = {
"simple": { label: "Simple (+0)", value: "0" },
"careful": { label: "Careful (Red +5)", value: "+4" },
@@ -321,6 +328,7 @@ export const SYSTEM = {
RANGE_CHOICES,
FAVOR_CHOICES,
ATTACKER_AIM_CHOICES,
ATTACKER_MOVEMENT_CHOICES,
MORTAL_CHOICES,
SPELL_CRITICAL,
MIRACLE_TYPES,
+55 -17
View File
@@ -72,23 +72,32 @@ export default class LethalFantasyActor extends Actor {
/* *************************************************/
async applyDamage(hpLoss) {
let hp = this.system.hp.value + hpLoss
if (hp < 0) {
hp = 0
await this.update({ "system.hp.value": hp })
}
/* *************************************************/
getNaturalDR() {
if (this.type === "monster") {
return Number(this.system.hp?.damageResistance) || 0
}
this.update({ "system.hp.value": hp })
return Number(this.system.biodata?.naturalDR) || 0
}
/* *************************************************/
getMagicDR() {
if (this.type === "monster") return 0
return Number(this.system.biodata?.magicDR) || 0
}
/* *************************************************/
computeDamageReduction() {
// Pour les monstres, utiliser hp.damageResistance et combat.damageReduction
if (this.type === "monster") {
let hpDR = Number(this.system.hp?.damageResistance) || 0
let hpDR = this.getNaturalDR()
let combatDR = Number(this.system.combat?.damageReduction) || 0
return hpDR + combatDR
}
// Pour les personnages, utiliser biodata et items
let naturalDR = Number(this.system.biodata?.naturalDR) || 0
let magicDR = Number(this.system.biodata?.magicDR) || 0
let naturalDR = this.getNaturalDR()
let magicDR = this.getMagicDR()
let armorDR = this.getArmorDR()
return naturalDR + magicDR + armorDR
}
@@ -153,8 +162,8 @@ export default class LethalFantasyActor extends Actor {
}
/* *************************************************/
async prepareRoll(rollType, rollKey, rollDice, defenderId, defenderTokenId, extraShieldDr = 0) {
console.log("Preparing roll", rollType, rollKey, rollDice, defenderId)
async prepareRoll(rollType, rollKey, rollDice, defenderId, defenderTokenId, extraShieldDr = 0, d30Effects = {}) {
log("Preparing roll", rollType, rollKey, rollDice, defenderId)
let rollTarget
switch (rollType) {
case "granted":
@@ -196,10 +205,39 @@ export default class LethalFantasyActor extends Actor {
case "spell-power":
case "miracle-attack":
case "miracle-power":
rollTarget = this.items.find((i) => (i.type === "miracle" || i.type == "spell") && i.id === rollKey)
rollTarget = foundry.utils.duplicate(this.items.find((i) => (i.type === "miracle" || i.type == "spell") && i.id === rollKey))
rollTarget.rollKey = rollKey
// Read damage tier from combatant currentAction if available
const activeCombatant = game.combat?.combatants?.find(c => c.actorId === this.id)
const currentAction = activeCombatant?.getFlag(SYSTEM.id, "currentAction")
let damageTier = currentAction?.damageTier
// No tier from combat action — prompt the user if multiple tiers exist
if (!damageTier) {
const tierMap = { standard: "damageDice", overpowered: "damageDiceOverpowered", overpowered2: "damageDiceOverpowered2" }
const available = Object.entries(tierMap).filter(([k, v]) => rollTarget.system?.[v])
if (available.length > 1) {
const buttons = available.map(([id]) => ({
action: id,
type: "button",
label: id.charAt(0).toUpperCase() + id.slice(1),
callback: () => id,
}))
damageTier = await foundry.applications.api.DialogV2.wait({
window: { title: "Choose spell tier" },
classes: ["lethalfantasy"],
content: `<p>Select the power level for <strong>${rollTarget.name}</strong>:</p>`,
buttons,
rejectClose: false,
}) || "standard"
} else {
damageTier = "standard"
}
}
rollTarget.damageTier = damageTier
if (rollType === "spell-attack" || rollType === "spell-power") {
const cost = Number(rollTarget.system?.cost) || 0
const tierCostMap = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" }
const costField = tierCostMap[damageTier] || "cost"
const cost = Number(rollTarget.system?.[costField]) || 0
const currentAether = Number(this.system.aetherPoints?.value) || 0
if (cost > currentAether) {
ui.notifications.warn(`${this.name} cannot cast ${rollTarget.name}: insufficient Aether (needs ${cost}, has ${currentAether}).`)
@@ -222,8 +260,7 @@ export default class LethalFantasyActor extends Actor {
rollTarget.rollKey = rollKey
}
break;
case "weapon-damage-small":
case "weapon-damage-medium":
case "weapon-damage":
case "weapon-attack":
case "weapon-defense": {
let weapon = this.items.find((i) => i.type === "weapon" && i.id === rollKey)
@@ -261,9 +298,10 @@ export default class LethalFantasyActor extends Actor {
weapon: weapon,
weaponSkillModifier: skill.weaponSkillModifier,
rollKey: rollKey,
combat: foundry.utils.duplicate(this.system.combat)
combat: foundry.utils.duplicate(this.system.combat),
isRangedAttack: weapon.system.weaponType === "ranged"
}
if (rollType === "weapon-damage-small" || rollType === "weapon-damage-medium") {
if (rollType === "weapon-damage") {
rollTarget.grantedDice = this.system.granted.damageDice
}
if (rollType === "weapon-attack") {
@@ -286,7 +324,7 @@ export default class LethalFantasyActor extends Actor {
rollTarget.magicUser = this.system.biodata.magicUser
rollTarget.actorModifiers = foundry.utils.duplicate(this.system.modifiers)
rollTarget.actorLevel = this.system.biodata.level
await this.system.roll(rollType, rollTarget, defenderId, defenderTokenId, extraShieldDr)
await this.system.roll(rollType, rollTarget, defenderId, defenderTokenId, extraShieldDr, d30Effects)
}
}
+16 -10
View File
@@ -1,3 +1,5 @@
import { log } from "../utils.mjs"
/**
* Classe pour gérer les résultats du D30 dans Lethal Fantasy
*/
@@ -40,7 +42,7 @@ export default class D30Roll {
this.resultsTable = data.d30_dice_results
this.definitions = data.definitions
console.log("D30Roll | D30 results table loaded successfully")
log("D30Roll | D30 results table loaded successfully")
} catch (error) {
console.error("D30Roll | Error loading D30 table:", error)
ui.notifications.error("Unable to load D30 results table")
@@ -111,17 +113,16 @@ export default class D30Roll {
if (externalType === "weapon-attack") {
if (!weapon) {
console.warn("D30Roll | Weapon object required for weapon-attack type")
return this.ROLL_TYPES.MELEE_ATTACK // Default to melee
// Fall through to use options.isRanged if available, otherwise default melee
}
return weapon.system?.weaponType === "ranged"
return (options.isRanged || weapon?.system?.weaponType === "ranged")
? this.ROLL_TYPES.RANGED_ATTACK
: this.ROLL_TYPES.MELEE_ATTACK
}
// Monster attacks - default to melee
// Monster attacks - check options.isRanged (set from rollTarget.attackMode) or weapon type
if (externalType === "monster-attack") {
// Check if weapon object has range information
if (weapon?.system?.weaponType === "ranged") {
if (options.isRanged || weapon?.system?.weaponType === "ranged") {
return this.ROLL_TYPES.RANGED_ATTACK
}
return this.ROLL_TYPES.MELEE_ATTACK
@@ -132,17 +133,22 @@ 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
}
// Skill types
if (externalType === "skill" || externalType === "monster-skill" ||
externalType === "save" || externalType === "challenge") {
if (externalType === "skill" || externalType === "monster-skill" || externalType === "challenge") {
return this.ROLL_TYPES.SKILL_ROLLS
}
// Saving throw types
if (externalType === "save") {
return this.ROLL_TYPES.ARCANE_SPELL_DEFENSE
}
// If no match, return null
console.warn(`D30Roll | Unknown external roll type: ${externalType}`)
return null
+306 -50
View File
@@ -127,6 +127,7 @@ export default class LethalFantasyRoll extends Roll {
* @returns {Promise<Object|null>} The roll result or null if the dialog was cancelled.
*/
static async prompt(options = {}) {
try {
let dice = "1D20"
let maxValue = 20
let baseFormula = "1D20"
@@ -139,7 +140,7 @@ export default class LethalFantasyRoll extends Roll {
let hasGrantedDice = false
let pointBlank = false
let letItFly = false
let saveSpell = false
let saveSpell = game.lethalFantasy?.spellDefense ?? false
let beyondSkill = false
let hasStaticModifier = false
let hasExplode = true
@@ -237,6 +238,7 @@ export default class LethalFantasyRoll extends Roll {
baseFormula = "D20"
hasModifier = true
hasChangeDice = false
hasFavor = true
options.rollTarget.value = options.rollTarget.actorModifiers.levelSpellModifier + options.rollTarget.actorModifiers.intSpellModifier
options.rollTarget.charModifier = options.rollTarget.actorModifiers.intSpellModifier
hasStaticModifier = options.rollType === "spell-power"
@@ -253,6 +255,7 @@ export default class LethalFantasyRoll extends Roll {
dice = "1D20"
baseFormula = "D20"
hasChangeDice = false
hasFavor = true
options.rollTarget.value = options.rollTarget.actorModifiers.levelMiracleModifier + options.rollTarget.actorModifiers.chaMiracleModifier
options.rollTarget.charModifier = options.rollTarget.actorModifiers.chaMiracleModifier
hasStaticModifier = options.rollType === "miracle-power"
@@ -283,13 +286,7 @@ export default class LethalFantasyRoll extends Roll {
let damageBonus = (options.rollTarget.weapon.system.applyStrengthDamageBonus) ? options.rollTarget.combat.damageModifier : 0
options.rollTarget.value = damageBonus + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.damageBonus
options.rollTarget.charModifier = damageBonus
if (options.rollType.includes("small")) {
options.damageSmall = true
dice = options.rollTarget.weapon.system.damage.damageS
} else {
options.damageMedium = true
dice = options.rollTarget.weapon.system.damage.damageM
}
dice = options.rollTarget.weapon.system.damage.damageM
if (/NE$/i.test(dice)) {
hasMaxValue = false
hasExplode = false
@@ -336,6 +333,10 @@ export default class LethalFantasyRoll extends Roll {
let modifier = "+0"
let targetName
// True for any ranged attack: PC weapon (ranged type) or monster attack (ranged mode)
const isRangedAttack = (options.rollType === "weapon-attack" && options.rollTarget?.weapon?.system?.weaponType === "ranged")
|| (options.rollType === "monster-attack" && options.rollTarget?.attackMode === "ranged")
let dialogContext = {
rollType: options.rollType,
rollTarget: options.rollTarget,
@@ -358,9 +359,10 @@ export default class LethalFantasyRoll extends Roll {
dice,
hasTarget: options.hasTarget,
modifier,
saveSpell: false,
saveSpell,
favor: "none",
targetName
targetName,
isRangedAttack
}
let rollContext
if (options.rollContext) {
@@ -389,9 +391,11 @@ export default class LethalFantasyRoll extends Roll {
position,
buttons: [
{
action: "roll",
type: "button",
label: label,
callback: (event, button, dialog) => {
console.log("Roll context", event, button, dialog)
log("Roll context", event, button, dialog)
let position = dialog?.position
game.user.setFlag(SYSTEM.id, "roll-dialog-pos", foundry.utils.duplicate(position))
const output = Array.from(button.form.elements).reduce((obj, input) => {
@@ -434,7 +438,7 @@ export default class LethalFantasyRoll extends Roll {
// If the user cancels the dialog, exit
if (rollContext === null) return
console.log("rollContext", rollContext, hasGrantedDice)
log("rollContext", rollContext, hasGrantedDice)
rollContext.saveSpell = saveSpell // Update fucking flag
let fullModifier = 0
@@ -443,7 +447,7 @@ export default class LethalFantasyRoll extends Roll {
if (hasModifier) {
let bonus = Number(options.rollTarget.value)
fullModifier = rollContext.modifier === "" ? 0 : parseInt(rollContext.modifier, 10) + bonus
fullModifier += (rollContext.saveSpell) ? options.rollTarget.actorModifiers.saveModifier : 0
fullModifier += (rollContext.saveSpell) ? (options.rollTarget.actorModifiers?.saveModifier ?? 0) : 0
if (Number(rollContext.attackerAim) > 0) {
fullModifier += Number(rollContext.attackerAim)
}
@@ -522,8 +526,6 @@ export default class LethalFantasyRoll extends Roll {
rollMode: rollContext.visibility,
hasTarget: options.hasTarget,
isDamage: options.isDamage,
damageSmall: options.damageSmall,
damageMedium: options.damageMedium,
pointBlank,
beyondSkill,
letItFly,
@@ -553,7 +555,7 @@ export default class LethalFantasyRoll extends Roll {
if (rollContext.favor === "favor") {
rollFavor = new this(baseFormula, options.data, rollData)
await rollFavor.evaluate()
console.log("Rolling with favor", rollFavor)
log("Rolling with favor", rollFavor)
if (game?.dice3d) {
game.dice3d.showForRoll(rollFavor, game.user, true)
}
@@ -592,12 +594,17 @@ export default class LethalFantasyRoll extends Roll {
}
options.D30result = rollD30.total
// Récupérer le message D30 correspondant
// Compute isRanged for D30: covers defense (isRangedDefense), monster ranged attacks (attackMode),
// and PC weapon attacks (isRangedAttack or weaponType)
const isRangedForD30 = options.isRangedDefense
|| options.rollTarget?.attackMode === "ranged"
|| options.rollTarget?.isRangedAttack === true
|| options.rollTarget?.weapon?.system?.weaponType === "ranged"
const d30Message = D30Roll.getResult(
rollD30.total,
options.rollType,
options.rollTarget?.weapon,
{ isRanged: options.isRangedDefense }
{ isRanged: isRangedForD30, isSpellSave: saveSpell }
)
options.D30message = d30Message
}
@@ -609,19 +616,20 @@ 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) {
while (diceResult === maxValue) {
let r = await new Roll(baseFormula).evaluate()
if (game?.dice3d) {
await game.dice3d.showForRoll(r, game.user, true)
}
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 })
}
}
}
@@ -661,6 +669,10 @@ export default class LethalFantasyRoll extends Roll {
rollBase.options.defenderId = options.defenderId
rollBase.options.defenderTokenId = options.defenderTokenId
rollBase.options.extraShieldDr = options.extraShieldDr || 0
rollBase.options.damageTier = options.damageTier || "standard"
rollBase.options.d30Bleed = options.d30Bleed || false
rollBase.options.d30DamageMultiplier = options.d30DamageMultiplier || 1
rollBase.options.d30DrMultiplier = options.d30DrMultiplier || 1
/**
* A hook event that fires after the roll has been made.
@@ -674,6 +686,10 @@ export default class LethalFantasyRoll extends Roll {
if (Hooks.call("fvtt-lethal-fantasy.Roll", options, rollData, rollBase) === false) return
return rollBase
} finally {
// Clear one-shot flag so it doesn't leak to subsequent non-spell saves
if (game.lethalFantasy) game.lethalFantasy.spellDefense = false
}
}
/* ***********************************************************/
@@ -709,6 +725,8 @@ export default class LethalFantasyRoll extends Roll {
content,
buttons: [
{
action: "initiative",
type: "button",
label: label,
callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => {
@@ -775,9 +793,19 @@ export default class LethalFantasyRoll extends Roll {
let buttons = []
if (currentAction) {
if (currentAction.type === "weapon") {
let weaponLabel = "Roll progression dice"
if (currentAction.rangedMode) {
// Compute loading count from the speed formula (e.g. "3+1d6" → load=3)
const speedStr = currentAction.system?.speed?.[currentAction.rangedMode] ?? ""
const rangedLoad = currentAction.rangedLoad ?? (Number(speedStr.split("+")[0]) || 0)
if (rangedLoad > 0 && !currentAction.weaponLoaded) {
weaponLabel = "Load weapon"
}
}
buttons.push({
action: "roll",
label: "Roll progression dice",
type: "button",
label: weaponLabel,
callback: (event, button) => {
let pos = $('#combat-action-dialog').position()
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
@@ -803,6 +831,7 @@ export default class LethalFantasyRoll extends Roll {
}
buttons.push({
action: "roll",
type: "button",
label: label,
callback: (event, button) => {
let pos = $('#combat-action-dialog').position()
@@ -814,6 +843,7 @@ export default class LethalFantasyRoll extends Roll {
} else {
buttons.push({
action: "roll",
type: "button",
label: "Select action",
callback: (event, button) => {
let pos = $('#combat-action-dialog').position()
@@ -829,6 +859,7 @@ export default class LethalFantasyRoll extends Roll {
}
buttons.push({
action: "cancel",
type: "button",
label: "Other action, not listed here",
callback: (event, button) => {
let pos = $('#combat-action-dialog').position()
@@ -847,12 +878,12 @@ export default class LethalFantasyRoll extends Roll {
rejectClose: false // Click on Close button will not launch an error
})
console.log("RollContext", dialogContext, rollContext)
log("RollContext", dialogContext, rollContext)
// If action is cancelled, exit
if (rollContext === null || rollContext === "cancel") {
await combatant.setFlag(SYSTEM.id, "currentAction", "")
let message = `${combatant.name} : Other action, progression reset`
ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
return
}
@@ -882,12 +913,36 @@ export default class LethalFantasyRoll extends Roll {
actionItem.progressionCount = firstActionTaken ? 1 : (combatant.actor.system.combat?.combatProgressionStart ?? 1)
if (!firstActionTaken) await combatant.setFlag(SYSTEM.id, "firstActionTaken", true)
actionItem.rangedMode = rangedMode
// If this is a spell/miracle with multiple damage tiers, prompt tier choice
if (actionItem.system?.damageDice) {
const tiers = [
{ id: "standard", label: "Standard", dice: actionItem.system.damageDice },
{ id: "overpowered", label: "Overpowered", dice: actionItem.system.damageDiceOverpowered },
{ id: "overpowered2", label: "Overpowered 2", dice: actionItem.system.damageDiceOverpowered2 },
].filter(t => t.dice)
if (tiers.length > 1) {
const tierChoice = await foundry.applications.api.DialogV2.wait({
window: { title: "Choose Damage Tier" },
classes: ["lethalfantasy"],
content: `<div class="grit-luck-dialog"><p><strong>${selectedItem.name}</strong> has multiple damage tiers.</p><p>Choose which damage to use when the attack lands:</p></div>`,
buttons: tiers.map(t => ({
action: t.id,
type: "button",
label: `${t.label} (${t.dice.toUpperCase()})`,
icon: "fa-solid fa-wand-magic-sparkles",
callback: () => t.id
})),
rejectClose: false
})
actionItem.damageTier = tierChoice || "standard"
}
}
actionItem.castingTime = 1
actionItem.spellStatus = "castingTime"
// Set the flag on the combatant
await combatant.setFlag(SYSTEM.id, "currentAction", actionItem)
let message = `${combatant.name} action : ${selectedItem.name}, start rolling progression dice or casting time`
ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
rollContext = (actionItem.type === "weapon") ? "rollProgressionDice" : "rollLethargyDice" // Set the roll context to rollProgressionDice
currentAction = actionItem
}
@@ -898,13 +953,14 @@ export default class LethalFantasyRoll extends Roll {
let time = currentAction.type === "spell" ? currentAction.system.castingTime : currentAction.system.prayerTime
if (currentAction.castingTime < time) {
let message = `Casting time : ${currentAction.name}, count : ${currentAction.castingTime}/${time}`
ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.castingTime += 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
return
} else {
let message = `Spell/Miracle ${currentAction.name} ready to be cast on next second !`
ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
// Last counting second — announce ready and transition immediately (no extra second consumed)
let message = `Casting time : ${currentAction.name}, count : ${time}/${time} — ready to cast next second !`
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.castingTime = 1
currentAction.spellStatus = "toBeCasted"
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
@@ -944,7 +1000,7 @@ export default class LethalFantasyRoll extends Roll {
isLethargy: true
}
)
ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
// Update the combatant progression count
await combatant.setFlag(SYSTEM.id, "currentAction", "")
// Display the action selection window again
@@ -964,7 +1020,7 @@ export default class LethalFantasyRoll extends Roll {
isLethargy: true
}
)
ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
}
}
}
@@ -976,18 +1032,18 @@ export default class LethalFantasyRoll extends Roll {
let split = toSplit.split("+")
currentAction.rangedLoad = Number(split[0]) || 0
formula = split[1]
console.log("Ranged Mode", currentAction.rangedMode, currentAction.rangedLoad, formula)
log("Ranged Mode", currentAction.rangedMode, currentAction.rangedLoad, formula)
}
// Range weapon loading
if (!currentAction.weaponLoaded && currentAction.rangedLoad) {
if (currentAction.progressionCount <= currentAction.rangedLoad) {
if (currentAction.progressionCount < currentAction.rangedLoad) {
let message = `Ranged weapon ${currentAction.name} is loading, loading count : ${currentAction.progressionCount}/${currentAction.rangedLoad}`
ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.progressionCount += 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
} else {
let message = `Ranged weapon ${currentAction.name} is loaded !`
ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.weaponLoaded = true
currentAction.progressionCount = 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
@@ -1019,13 +1075,13 @@ export default class LethalFantasyRoll extends Roll {
rollResult: roll.total
}
)
ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await combatant.setFlag(SYSTEM.id, "currentAction", "")
combatant.actor.prepareRoll(currentAction.type === "weapon" ? "weapon-attack" : "spell-attack", currentAction._id)
} else {
// Notify that the player cannot act now with a chat message
currentAction.progressionCount += 1
combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
const messageContent = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
{
@@ -1036,7 +1092,7 @@ export default class LethalFantasyRoll extends Roll {
progressionCount: currentAction.progressionCount
}
)
ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
}
}
}
@@ -1076,6 +1132,8 @@ export default class LethalFantasyRoll extends Roll {
content,
buttons: [
{
action: "rangeDefense",
type: "button",
label: label,
callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => {
@@ -1092,7 +1150,7 @@ export default class LethalFantasyRoll extends Roll {
// If the user cancels the dialog, exit
if (rollContext === null) return
console.log("RollContext", rollContext)
log("RollContext", rollContext)
// Add disfavor/favor option if point blank range
if (rollContext.range === "pointblank") {
rollContext.movement = rollContext.movement.replace("kh", "")
@@ -1126,6 +1184,8 @@ export default class LethalFantasyRoll extends Roll {
options = { ...options, ...rollContext }
options.rollName = "Ranged Defense"
options.rollType = "weapon-defense"
options.type = options.rollType // Required: this.type reads options.type
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
const rollBase = new this(rollContext.movement, options.data, rollData)
const rollModifier = new Roll(modifierFormula, options.data, rollData)
@@ -1158,6 +1218,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 })
@@ -1193,6 +1254,164 @@ export default class LethalFantasyRoll extends Roll {
return rollBase
}
/**
* Prompts the GM for ranged attack context (movement, range, target size, aim) when a monster
* attacks with a ranged weapon, then evaluates an exploding D20 attack roll with the resulting modifiers.
*
* @param {Object} options Options for the roll.
* @param {string} options.actorId The attacker actor ID.
* @param {string} options.actorName The attacker actor name.
* @param {Object} options.rollTarget The rollTarget containing attackModifier and related data.
* @returns {Promise<LethalFantasyRoll|null>} The resulting roll, or null if cancelled.
*/
static async promptRangedAttack(options = {}) {
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes)
const fieldRollMode = new foundry.data.fields.StringField({
choices: rollModes,
blank: false,
default: "public",
})
let dialogContext = {
attackerMovementChoices: SYSTEM.ATTACKER_MOVEMENT_CHOICES,
rangeChoices: SYSTEM.RANGE_CHOICES,
sizeChoices: SYSTEM.SIZE_CHOICES,
attackerAimChoices: SYSTEM.ATTACKER_AIM_CHOICES,
movement: "none",
range: "short",
size: "+5",
attackerAim: "simple",
fieldRollMode,
rollModes
}
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/range-attack-dialog.hbs", dialogContext)
const label = game.i18n.localize("LETHALFANTASY.Label.rangeAttackRoll")
const rollContext = await foundry.applications.api.DialogV2.wait({
window: { title: "Ranged Attack" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "rangedAttack",
type: "button",
label,
callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
return output
},
},
],
rejectClose: false
})
if (rollContext === null) return null
// Handle pointblank: attacker at point blank gets favor (standing still easier to aim)
if (rollContext.range === "pointblank") {
rollContext.movement = rollContext.movement.replace("kh", "")
rollContext.movement = rollContext.movement.replace("kl", "")
rollContext.movement += "kh" // Favor for attacker at point blank
rollContext.range = "0"
}
// Handle beyondskill: extreme range gives disfavor to attacker
if (rollContext.range === "beyondskill") {
rollContext.movement = rollContext.movement.replace("kh", "")
rollContext.movement = rollContext.movement.replace("kl", "")
rollContext.movement += "kl" // Disfavor for attacker beyond skill range
rollContext.range = "+11"
}
// Compute contextual penalty: range + target_size, reduced by aim bonus and attack modifier
const attackModifier = options.rollTarget?.attackModifier ?? 0
const contextualPenalty = Number(rollContext.range) + Number(rollContext.size)
const aimBonus = Number(rollContext.attackerAim || 0)
const fullModifier = contextualPenalty - aimBonus - attackModifier
let modifierFormula
if (fullModifier === 0) {
modifierFormula = "0"
} else {
const modAbs = Math.abs(fullModifier)
modifierFormula = `D${modAbs + 1} -1`
}
const rollData = { ...rollContext }
options = { ...options, ...rollContext }
options.rollName = "Ranged Attack"
options.rollType = options.rollType || "monster-attack"
options.type = options.rollType // Required: this.type reads options.type, used to build weaponDamageOptions in toHTML
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
options.isRangedAttack = true
const rollBase = new this(rollContext.movement, options.data, rollData)
const rollModifier = new Roll(modifierFormula, options.data, rollData)
rollModifier.evaluate()
await rollBase.evaluate()
const rollD30 = await new Roll("1D30").evaluate()
options.D30result = rollD30.total
options.D30message = D30Roll.getResult(rollD30.total, options.rollType, undefined, { isRanged: true })
// Determine favor from dice formula
let badResult = 0
if (rollContext.movement.includes("kh")) {
rollData.favor = "favor"
badResult = Math.min(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 20)
}
if (rollContext.movement.includes("kl")) {
rollData.favor = "disfavor"
badResult = Math.max(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 1)
}
const dice = rollContext.movement
const maxValue = 20
let rollTotal = -1
let diceResults = []
let diceResult = rollBase.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}`, value: diceResult })
let diceSum = diceResult
// Exploding dice
while (diceResult === maxValue) {
const r = await new Roll(dice).evaluate()
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 })
if (fullModifier > 0) {
// Net penalty: subtract from roll
rollTotal = Math.max(diceSum - rollModifier.total, 0)
} else {
// Net bonus: add to roll
rollTotal = diceSum + rollModifier.total
}
} else {
rollTotal = diceSum
}
rollBase.options = { ...rollBase.options, ...options }
rollBase.options.resultType = undefined
rollBase.options.rollTotal = rollTotal
rollBase.options.diceResults = diceResults
rollBase.options.rollTarget = options.rollTarget
rollBase.options.titleFormula = `1D20E + ${modifierFormula}`
rollBase.options.D30result = options.D30result
rollBase.options.D30message = options.D30message
rollBase.options.rollName = "Ranged Attack"
rollBase.options.badResult = badResult
rollBase.options.rollData = foundry.utils.duplicate(rollData)
return rollBase
}
/**
* Creates a title based on the given type.
*
@@ -1213,10 +1432,8 @@ export default class LethalFantasyRoll extends Roll {
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-attack")}`
case "weapon-defense":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-defense")}`
case "weapon-damage-small":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-damage-small")}`
case "weapon-damage-medium":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-damage-medium")}`
case "weapon-damage":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-damage")}`
case "spell":
case "spell-attack":
case "spell-power":
@@ -1233,7 +1450,7 @@ export default class LethalFantasyRoll extends Roll {
/** @override */
async render(chatOptions = {}) {
let chatData = await this._getChatCardData(chatOptions.isPrivate)
console.log("ChatData", chatData)
log("ChatData", chatData)
return await foundry.applications.handlebars.renderTemplate(this.constructor.CHAT_TEMPLATE, chatData)
}
@@ -1271,16 +1488,15 @@ export default class LethalFantasyRoll extends Roll {
// Récupérer les informations de l'arme pour les attaques réussies
let weaponDamageOptions = null
console.log("Roll type:", this.type, "rollTarget:", this.rollTarget, "Has weapon:", !!this.rollTarget?.weapon)
log("Roll type:", this.type, "rollTarget:", this.rollTarget, "Has weapon:", !!this.rollTarget?.weapon)
if (this.type === "weapon-attack" && this.rollTarget?.weapon) {
const weapon = this.rollTarget.weapon
weaponDamageOptions = {
weaponId: weapon._id || weapon.id,
weaponId: weapon.id,
weaponName: weapon.name,
damageS: weapon.system?.damage?.damageS,
damageM: weapon.system?.damage?.damageM
}
console.log("Weapon damage options:", weaponDamageOptions)
log("Weapon damage options:", weaponDamageOptions)
} else if (this.type === "monster-attack" && this.rollTarget) {
weaponDamageOptions = {
weaponId: this.rollTarget.rollKey,
@@ -1289,7 +1505,7 @@ export default class LethalFantasyRoll extends Roll {
damageModifier: this.rollTarget.damageModifier,
isMonster: true
}
console.log("Monster damage options:", weaponDamageOptions)
log("Monster damage options:", weaponDamageOptions)
}
const cardData = {
@@ -1360,4 +1576,44 @@ export default class LethalFantasyRoll extends Roll {
)
}
/**
* Evaluate a spell/miracle damage formula with per-die explosion, then post to chat.
* Explosion dice are shown manually via showForRoll; the main roll is shown automatically
* by toMessage() (which triggers Dice So Nice via its createChatMessage hook).
* Append "NE" to the formula to disable explosion.
*
* @param {string} formula Dice formula, e.g. "1d8", "2d6", "1d8NE"
* @param {Object} rollOpts Options for LethalFantasyRoll (rollType, actorId, defenderId, etc.)
* @returns {Promise<ChatMessage>}
*/
static async rollSpellDamageToMessage(formula, rollOpts) {
const roll = new LethalFantasyRoll(formula, {}, rollOpts)
await roll.evaluate()
const shouldExplode = !/NE$/i.test(formula)
const diceResults = []
let diceSum = 0
for (const term of roll.dice) {
const singleDice = `1D${term.faces}`
const termResults = Array.from(term.results)
for (const r of termResults) {
let diceResult = r.result
diceResults.push({ dice: singleDice.toUpperCase(), value: diceResult })
diceSum += diceResult
if (shouldExplode && term.faces > 0) {
while (diceResult === term.faces) {
const xr = await new Roll(singleDice).evaluate()
// Optional chaining guards against unexpected roll structure
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 })
}
}
}
}
roll.options.diceResults = diceResults
roll.options.rollTotal = diceSum
return roll.toMessage()
}
}
+5 -5
View File
@@ -32,20 +32,20 @@ export class Macros {
dropData.rollType === "save"
? `game.actors.get('${dropData.actorId}').system.roll('${dropData.rollType}', '${dropData.rollTarget}', '=');`
: `game.actors.get('${dropData.actorId}').system.roll('${dropData.rollType}', '${dropData.rollTarget}');`
const rollName = `${game.i18n.localize("TENEBRIS.Label.jet")} ${game.i18n.localize(`TENEBRIS.Manager.${dropData.rollTarget}`)}`
const rollName = `${game.i18n.localize("LETHALFANTASY.Label.jet")} ${game.i18n.localize(`LETHALFANTASY.Label.${dropData.rollTarget}`)}`
this.createMacro(slot, rollName, rollCommand, "icons/svg/d20-grey.svg")
break
case "rollDamage":
const weapon = game.actors.get(dropData.actorId).items.get(dropData.rollTarget)
const rollDamageCommand = `game.actors.get('${dropData.actorId}').system.roll('${dropData.rollType}', '${dropData.rollTarget}');`
const rollDamageName = `${game.i18n.localize("TENEBRIS.Label.jet")} ${weapon.name}`
const rollDamageName = `${game.i18n.localize("LETHALFANTASY.Label.jet")} ${weapon.name}`
this.createMacro(slot, rollDamageName, rollDamageCommand, weapon.img)
break
case "rollAttack":
const rollAttackCommand = `game.actors.get('${dropData.actorId}').system.roll('${dropData.rollValue}', '${dropData.rollTarget}');`
const rollAttackName = `${game.i18n.localize("TENEBRIS.Label.jet")} ${dropData.rollTarget}`
const rollAttackName = `${game.i18n.localize("LETHALFANTASY.Label.jet")} ${dropData.rollTarget}`
this.createMacro(slot, rollAttackName, rollAttackCommand, "icons/svg/d20-grey.svg")
break
@@ -57,7 +57,7 @@ export class Macros {
/**
* Create a macro
* All macros are flaged with a tenebris.macro flag at true
* All macros are flaged with a lethalFantasy.macro flag at true
* @param {*} slot
* @param {*} name
* @param {*} command
@@ -72,7 +72,7 @@ export class Macros {
type: "script",
img: img,
command: command,
flags: { "tenebris.macro": true },
flags: { "lethalFantasy.macro": true },
},
{ displaySheet: false },
)
+7 -3
View File
@@ -65,7 +65,7 @@ export default class LethalFantasyCharacter extends foundry.abstract.TypeDataMod
}
schema.hp = new fields.SchemaField({
value: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }),
value: new fields.NumberField({ ...requiredInteger, initial: 1 }),
max: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }),
painDamage: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }),
wounds: new fields.ArrayField(new fields.SchemaField(woundFieldSchema), {
@@ -281,7 +281,7 @@ export default class LethalFantasyCharacter extends foundry.abstract.TypeDataMod
* @param {"="|"+"|"++"|"-"|"--"} rollAdvantage If there is an avantage (+), a disadvantage (-), a double advantage (++), a double disadvantage (--) or a normal roll (=).
* @returns {Promise<null>} - A promise that resolves to null if the roll is cancelled.
*/
async roll(rollType, rollTarget, defenderId, defenderTokenId, extraShieldDr = 0) {
async roll(rollType, rollTarget, defenderId, defenderTokenId, extraShieldDr = 0, d30Effects = {}) {
const hasTarget = false
let roll = await LethalFantasyRoll.prompt({
rollType,
@@ -293,7 +293,11 @@ export default class LethalFantasyCharacter extends foundry.abstract.TypeDataMod
target: false,
defenderId,
defenderTokenId,
extraShieldDr
extraShieldDr,
damageTier: rollTarget.damageTier || "standard",
d30Bleed: d30Effects.d30Bleed || false,
d30DamageMultiplier: d30Effects.d30DamageMultiplier || 1,
d30DrMultiplier: d30Effects.d30DrMultiplier || 1
})
if (!roll) return null
+47 -10
View File
@@ -56,10 +56,28 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
}, {}),
)
const woundFieldSchema = {
value: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }),
duration: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }),
description: new fields.StringField({ initial: "", required: false, nullable: true }),
}
schema.hp = new fields.SchemaField({
value: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }),
value: new fields.NumberField({ ...requiredInteger, initial: 1 }),
average: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }),
max: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }),
wounds: new fields.ArrayField(new fields.SchemaField(woundFieldSchema), {
initial: [
{ description: "", value: 0, duration: 0 },
{ description: "", value: 0, duration: 0 },
{ description: "", value: 0, duration: 0 },
{ description: "", value: 0, duration: 0 },
{ description: "", value: 0, duration: 0 },
{ description: "", value: 0, duration: 0 },
{ description: "", value: 0, duration: 0 },
{ description: "", value: 0, duration: 0 }
], min: 8
}),
damageResistance: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }),
painDamage: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 })
})
@@ -164,8 +182,24 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
* @param {"="|"+"|"++"|"-"|"--"} rollAdvantage If there is an avantage (+), a disadvantage (-), a double advantage (++), a double disadvantage (--) or a normal roll (=).
* @returns {Promise<null>} - A promise that resolves to null if the roll is cancelled.
*/
async roll(rollType, rollTarget, defenderId = undefined, defenderTokenId = undefined, extraShieldDr = 0) {
async roll(rollType, rollTarget, defenderId = undefined, defenderTokenId = undefined, extraShieldDr = 0, d30Effects = {}) {
const hasTarget = false
// Ranged monster defense uses the ranged defense dialog (movement, range, size modifiers)
if (rollType === "monster-defense" && rollTarget?.isRangedDefense === true) {
let roll = await LethalFantasyRoll.promptRangedDefense({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
rollTarget,
defenderId,
defenderTokenId,
})
if (!roll) return null
await roll.toMessage({}, { messageMode: roll.options.rollMode })
return
}
let roll = await LethalFantasyRoll.prompt({
rollType,
rollTarget,
@@ -176,14 +210,18 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
target: false,
defenderId,
defenderTokenId,
extraShieldDr
extraShieldDr,
damageTier: rollTarget.damageTier || "standard",
d30Bleed: d30Effects.d30Bleed || false,
d30DamageMultiplier: d30Effects.d30DamageMultiplier || 1,
d30DrMultiplier: d30Effects.d30DrMultiplier || 1
})
if (!roll) return null
await roll.toMessage({}, { messageMode: roll.options.rollMode })
}
async prepareMonsterRoll(rollType, rollKey, rollDice = undefined, tokenId = undefined, damageModifier = undefined, defenderId = undefined, defenderTokenId = undefined, extraShieldDr = 0) {
async prepareMonsterRoll(rollType, rollKey, rollDice = undefined, tokenId = undefined, damageModifier = undefined, defenderId = undefined, defenderTokenId = undefined, extraShieldDr = 0, d30Effects = {}) {
let rollTarget
switch (rollType) {
case "monster-attack":
@@ -236,8 +274,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
}, { messageMode: roll.options.rollMode ?? game.settings.get("core", "rollMode") })
return
}
case "weapon-damage-small":
case "weapon-damage-medium":
case "weapon-damage":
case "weapon-attack":
case "weapon-defense": {
let weapon = this.actor.items.find((i) => i.type === "weapon" && i.id === rollKey)
@@ -284,7 +321,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
// In all cases
if (rollTarget) {
rollTarget.tokenId = tokenId
await this.roll(rollType, rollTarget, defenderId, defenderTokenId, extraShieldDr)
await this.roll(rollType, rollTarget, defenderId, defenderTokenId, extraShieldDr, d30Effects)
}
}
@@ -346,7 +383,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
rollResult: roll.total
}
)
ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: this.parent }) })
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: this.parent }) })
let token = combatant?.token
this.prepareMonsterRoll("monster-attack", key, undefined, token?.id)
if (token?.object) {
@@ -370,7 +407,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
rollResult: roll.total
}
)
ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: this.parent }) })
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: this.parent }) })
let token = combatant?.token
this.prepareMonsterRoll("monster-attack-hth", key, undefined, token?.id)
if (token?.object) {
@@ -389,7 +426,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
rollResult: roll.total
}
)
ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: this.parent }) })
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: this.parent }) })
}
}
+2
View File
@@ -19,6 +19,8 @@ export default class LethalFantasySpell extends foundry.abstract.TypeDataModel {
})
schema.cost = new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 })
schema.costOverpowered = new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 })
schema.costOverpowered2 = new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 })
schema.memorized = new fields.BooleanField({ required: true, initial: false })
schema.components = new fields.SchemaField({
+6 -5
View File
@@ -1,6 +1,7 @@
import { SYSTEM } from "../config/system.mjs"
import { WEAPON_TYPE } from "../config/weapon.mjs"
export default class LethalFantasySkill extends foundry.abstract.TypeDataModel {
export default class LethalFantasyWeapon extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const schema = {}
@@ -45,9 +46,9 @@ export default class LethalFantasySkill extends foundry.abstract.TypeDataModel {
})
schema.bonuses = new fields.SchemaField({
attackBonus: new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 }),
damageBonus: new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 }),
defenseBonus: new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 })
attackBonus: new fields.NumberField({ ...requiredInteger, required: true, initial: 0 }),
damageBonus: new fields.NumberField({ ...requiredInteger, required: true, initial: 0 }),
defenseBonus: new fields.NumberField({ ...requiredInteger, required: true, initial: 0 })
})
schema.encLoad = new fields.NumberField({ required: true, initial: 0, min: 0 })
@@ -62,7 +63,7 @@ export default class LethalFantasySkill extends foundry.abstract.TypeDataModel {
static LOCALIZATION_PREFIXES = ["LETHALFANTASY.Weapon"]
get weaponCategory() {
return game.i18n.localize(CATEGORY[this.weaponType].label)
return game.i18n.localize(WEAPON_TYPE[this.weaponType] || this.weaponType)
}
}
+546 -51
View File
@@ -1,8 +1,9 @@
import { SYSTEM } from "./config/system.mjs"
// Map temporaire pour stocker les données d'attaque en attente de défense
if (!globalThis.pendingDefenses) {
globalThis.pendingDefenses = new Map()
export function log(...args) {
if (game?.settings?.get(game.system.id, "debug")) {
console.log(...args)
}
}
export default class LethalFantasyUtils {
@@ -27,7 +28,12 @@ export default class LethalFantasyUtils {
/* -------------------------------------------- */
static setHookListeners() {
Hooks.on('renderTokenHUD', async (hud, html, token) => {
Hooks.on('renderTokenHUD', async (hud, html, data) => {
if (html.querySelector(".lethal-hp-loss-hud")) return
// The token/actor is on the HUD application instance, not the third param.
// hud.token / hud.object gives the Token (PlaceableObject), which has .actor.
const hudActor = hud.token?.actor ?? hud.object?.actor
if (!hudActor) return
// HP Loss Button (existing)
const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {})
$(html).find('div.left').append(lossHPButton);
@@ -50,21 +56,16 @@ export default class LethalFantasyUtils {
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
}
})
$(html).find('.loss-hp-hud-click').click((event) => {
$(html).find('.loss-hp-hud-click').click(async (event) => {
event.preventDefault();
let hpLoss = event.currentTarget.dataset.hpValue;
if (token) {
let tokenFull = canvas.tokens.placeables.find(t => t.id === token._id);
console.log(tokenFull, token)
let actor = tokenFull.actor;
actor.applyDamage(Number(hpLoss));
await hudActor.applyDamage(Number(hpLoss));
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
}
})
// HP Gain Button (new)
@@ -89,28 +90,59 @@ export default class LethalFantasyUtils {
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
}
})
$(html).find('.gain-hp-hud-click').click((event) => {
$(html).find('.gain-hp-hud-click').click(async (event) => {
event.preventDefault();
let hpGain = event.currentTarget.dataset.hpValue;
if (token) {
let tokenFull = canvas.tokens.placeables.find(t => t.id === token._id);
console.log(tokenFull, token)
let actor = tokenFull.actor;
actor.applyDamage(Number(hpGain)); // Positive value to add HP
await hudActor.applyDamage(Number(hpGain)); // Positive value to add HP
// Clear bleeding wounds on heal — regardless of heal amount, any
// healing is enough to stop bleeding (field dressing / magic / rest).
const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || [])
const hadBleeding = wounds.some(w => w.description === "Bleeding")
if (hadBleeding) {
await hudActor.update({
"system.hp.wounds": wounds.map(w =>
w.description === "Bleeding" ? { value: 0, duration: 0 } : w
)
})
}
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
})
// Luck/Grit Buttons
const luckGritButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/luck-grit-hud.hbs', {})
$(html).find('div.left').append(luckGritButton);
$(html).find('.lethal-luck-grit-hud').click((event) => {
event.preventDefault();
let wrap = $(html).find('.luck-grit-wrap')[0]
if (wrap.classList.contains("luck-grit-hud-disabled")) {
wrap.classList.add('luck-grit-hud-active');
wrap.classList.remove('luck-grit-hud-disabled');
} else {
wrap.classList.remove('luck-grit-hud-active');
wrap.classList.add('luck-grit-hud-disabled');
}
})
$(html).find('.luck-grit-btn').click(async (event) => {
event.preventDefault();
const resource = event.currentTarget.dataset.resource;
const amount = Number(event.currentTarget.dataset.amount);
const current = Number(foundry.utils.getProperty(hudActor.system, `${resource}.current`)) || 0;
const newValue = Math.max(0, current + amount);
await hudActor.update({ [`system.${resource}.current`]: newValue });
$(html).find('.luck-grit-wrap')[0].classList.remove('luck-grit-hud-active');
$(html).find('.luck-grit-wrap')[0].classList.add('luck-grit-hud-disabled');
})
})
}
/* -------------------------------------------- */
static handleSocketEvent(msg = {}) {
console.log(`handleSocketEvent !`, msg)
static async handleSocketEvent(msg = {}) {
log(`handleSocketEvent !`, msg)
let actor
switch (msg.type) {
case "applyDamage":
@@ -120,18 +152,18 @@ export default class LethalFantasyUtils {
? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
: (game.combat?.combatants?.find(c => c.actorId === msg.actorId)?.actor
?? game.actors.get(msg.actorId))
if (actor) actor.applyDamage(msg.damage)
if (actor) await actor.applyDamage(msg.damage)
}
break
case "rollInitiative":
if (msg.userId && msg.userId !== game.user.id) break
actor = game.actors.get(msg.actorId)
actor.system.rollInitiative(msg.combatId, msg.combatantId)
await actor.system.rollInitiative(msg.combatId, msg.combatantId)
break
case "rollProgressionDice":
if (msg.userId && msg.userId !== game.user.id) break
actor = game.actors.get(msg.actorId)
actor.system.rollProgressionDice(msg.combatId, msg.combatantId, msg.rollProgressionCount)
await actor.system.rollProgressionDice(msg.combatId, msg.combatantId, msg.rollProgressionCount)
break
case "requestDefense":
// Vérifier si le message est destiné à cet utilisateur
@@ -145,6 +177,26 @@ export default class LethalFantasyUtils {
LethalFantasyUtils.handleAttackerGritOffer(msg)
}
break
case "applyBleeding":
if (game.user.isGM) {
actor = msg.tokenId
? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
: game.actors.get(msg.actorId)
if (actor && actor.system.hp?.wounds && msg.damage > 0) {
const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
const slot = wounds.findIndex(w => !w.value && !w.duration)
if (slot !== -1) {
wounds[slot] = { value: msg.damage, duration: msg.damage, description: "Bleeding" }
await actor.update({ "system.hp.wounds": wounds })
}
}
}
break
case "attackBoosted":
if (msg.userId === game.user.id) {
LethalFantasyUtils.handleAttackBoosted(msg)
}
break
}
}
@@ -182,6 +234,225 @@ export default class LethalFantasyUtils {
})
}
/* -------------------------------------------- */
static async handleAttackBoosted(msg) {
const {
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
shieldDamageReduction: initialShieldDR,
d30Bleed, d30DamageMultiplier, d30DrMultiplier,
damageTier, attackD30message, defenseD30message,
hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield
} = msg
const defender = game.actors.get(defenderId)
if (!defender) return
let updatedDefenseRoll = defenseRoll
let shieldBlocked = false
let shieldReaction = null
let canShieldReact = hasShield
let canAdHoc = canAdHocShield
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
let defenseDrMultiplier = null
if (defenseD30message && defender) {
const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true)
if (d30Result.modifier) {
updatedDefenseRoll += d30Result.modifier
if (d30Result.modifier > 0) {
await ChatMessage.create({
content: `<p><strong>${defenderName}</strong> gains <strong>+${d30Result.modifier}</strong> from D30 bonus die for defense.</p>`,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
}
if (d30Result.specialEffect === "auto") {
updatedDefenseRoll = attackRollFinal + 1
await ChatMessage.create({
content: `<p><strong>${defenderName}</strong> uses <strong>${d30Result.specialName || "Special Defense"}</strong> from D30 — defense automatically succeeds!</p>`,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
if (d30Result.specialEffect === "flag") {
await ChatMessage.create({
content: `<p>D30 — <strong>${d30Result.specialName || "Special Effect"}</strong> triggered for ${defenderName}!</p>`,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
if (d30Result.specialEffect === "drMultiplier") {
defenseDrMultiplier = d30Result.multiplier
await ChatMessage.create({
content: `<p>D30 — Defense grants <strong>x${d30Result.multiplier} DR</strong> (choose which DR types to multiply when damage is applied)</p>`,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
}
// Show the defense reaction dialog — while-loop for multiple reactions
if (defender) {
while (updatedDefenseRoll < attackRollFinal) {
const currentGrit = Number(defender.system?.grit?.current) || 0
const currentLuck = Number(defender.system?.luck?.current) || 0
const buttons = []
if (currentGrit > 0) {
buttons.push({
action: "grit",
type: "button",
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
icon: "fa-solid fa-fist-raised",
callback: () => "grit"
})
}
if (currentLuck > 0) {
buttons.push({
action: "luck",
type: "button",
label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
icon: "fa-solid fa-clover",
callback: () => "luck"
})
}
buttons.push({
action: "bonusDie",
type: "button",
label: "Add bonus die",
icon: "fa-solid fa-dice",
callback: () => "bonusDie"
})
if (canShieldReact) {
buttons.push({
action: "shieldReact",
type: "button",
label: `Roll shield (${shieldLabel})`,
icon: "fa-solid fa-shield",
callback: () => "shieldReact"
})
} else if (canAdHoc) {
buttons.push({
action: "adHocShield",
type: "button",
label: "Roll ad-hoc shield (choose dice + DR)",
icon: "fa-solid fa-shield-halved",
callback: () => "adHocShield"
})
}
buttons.push({
action: "continue",
type: "button",
label: "Continue (no defense bonus)",
icon: "fa-solid fa-forward",
callback: () => "continue"
})
const choice = await foundry.applications.api.DialogV2.wait({
window: { title: "Defense reactions — attack boosted" },
classes: ["lethalfantasy"],
content: `
<div class="grit-luck-dialog">
<div class="combat-status">
<p><strong>${attackerName}</strong> boosted attack to <strong>${attackRollFinal}</strong></p>
<p><strong>${defenderName}</strong> currently has <strong>${updatedDefenseRoll}</strong></p>
</div>
<p class="offer-text">The attack was boosted! Choose how to improve the defense.</p>
</div>
`,
buttons,
rejectClose: false
})
if (!choice || choice === "continue") break
if (choice === "grit") {
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender,
total => `<p><strong>${defenderName}</strong> spends 1 Grit and rolls <strong>${total}</strong> for defense.</p>`)
updatedDefenseRoll += bonusRoll
await defender.update({ "system.grit.current": currentGrit - 1 })
} else if (choice === "luck") {
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender,
total => `<p><strong>${defenderName}</strong> spends 1 Luck and rolls <strong>${total}</strong> for defense.</p>`)
updatedDefenseRoll += bonusRoll
await defender.update({ "system.luck.current": currentLuck - 1 })
} else if (choice === "bonusDie") {
const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", updatedDefenseRoll, attackRollFinal)
if (bonusDie) {
const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender,
(total, formula) => `<p><strong>${defenderName}</strong> adds <strong>${formula.toUpperCase()}</strong> and rolls <strong>${total}</strong> for defense.</p>`)
updatedDefenseRoll += bonusRoll
}
} else if (choice === "shieldReact" && canShieldReact) {
const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldFormula, defender)
const newDefenseTotal = updatedDefenseRoll + shieldBonus
updatedDefenseRoll = newDefenseTotal
canShieldReact = false
if (newDefenseTotal >= attackRollFinal) {
shieldBlocked = true
shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus }
await ChatMessage.create({
content: `<p><strong>${defenderName}</strong> rolls <strong>${shieldLabel}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal}${attackRollFinal}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${shieldDr}</strong> will apply to damage.</p>`,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
} else {
await ChatMessage.create({
content: `<p><strong>${defenderName}</strong> rolls <strong>${shieldLabel}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.</p>`,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
} else if (choice === "adHocShield" && canAdHoc) {
const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll)
if (adHoc) {
const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender)
const newDefenseTotal = updatedDefenseRoll + shieldBonus
updatedDefenseRoll = newDefenseTotal
canShieldReact = false
canAdHoc = false
if (newDefenseTotal >= attackRollFinal) {
shieldBlocked = true
shieldReaction = { damageReduction: adHoc.damageReduction, label: `${adHoc.formula.toUpperCase()} shield`, bonus: shieldBonus }
await ChatMessage.create({
content: `<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal}${attackRollFinal}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${adHoc.damageReduction}</strong> will apply to damage.</p>`,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
} else {
await ChatMessage.create({
content: `<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.</p>`,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
}
}
}
}
const finalShieldDR = shieldBlocked ? (shieldReaction?.damageReduction || 0) : 0
const outcome = shieldBlocked ? "shielded-hit" : (updatedDefenseRoll >= attackRollFinal ? "miss" : "hit")
await LethalFantasyUtils.compareAttackDefense({
attackerName,
attackerId,
attackRoll: attackRollFinal,
attackWeaponId,
attackRollType,
attackRollKey,
defenderName,
defenderId,
defenderTokenId,
defenseRoll: updatedDefenseRoll,
outcome,
shieldDamageReduction: finalShieldDR,
d30Bleed: d30Bleed || "",
d30DamageMultiplier: d30DamageMultiplier || 1,
d30DrMultiplier: (d30DrMultiplier != null && d30DrMultiplier !== 1) ? d30DrMultiplier : (defenseDrMultiplier ?? d30DrMultiplier ?? 1),
damageTier: damageTier || "standard",
attackD30message
})
}
/* -------------------------------------------- */
static async showDefenseRequest(msg) {
const attackerName = msg.attackerName
@@ -235,7 +506,7 @@ export default class LethalFantasyUtils {
const isMonster = defender.type === "monster"
console.log(`[LF] showDefenseRequest | attackRollType=${attackRollType} isMonster=${isMonster} defender=${defender?.name}`)
log(`[LF] showDefenseRequest | attackRollType=${attackRollType} isMonster=${isMonster} defender=${defender?.name}`)
// Spell/miracle attacks use saving throws instead of weapon defense
const isSpellAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
@@ -269,6 +540,7 @@ export default class LethalFantasyUtils {
buttons: [
{
action: "rollSave",
type: "button",
label: "Roll Save",
icon: "fa-solid fa-person-running",
callback: (event, button) => button.form.elements.saveKey.value,
@@ -279,6 +551,7 @@ export default class LethalFantasyUtils {
if (result) {
game.lethalFantasy = game.lethalFantasy || {}
game.lethalFantasy.spellDefense = true // pré-cocher "Save against spell" dans le dialog
game.lethalFantasy.nextDefenseData = {
attackerId,
attackRoll,
@@ -290,13 +563,15 @@ export default class LethalFantasyUtils {
attackD30result,
attackD30message,
attackRerollContext,
attackNaturalRoll: msg.attackNaturalRoll,
damageTier: msg.damageTier,
defenderId: defender.id,
defenderTokenId
}
if (isMonster) {
defender.system.prepareMonsterRoll("save", result)
await defender.system.prepareMonsterRoll("save", result)
} else {
defender.prepareRoll("save", result)
await defender.prepareRoll("save", result)
}
}
return
@@ -339,6 +614,8 @@ export default class LethalFantasyUtils {
content,
buttons: [
{
action: "rangeDefense",
type: "button",
label: "Roll Defense",
icon: "fa-solid fa-shield",
callback: (event, button, dialog) => {
@@ -365,12 +642,14 @@ export default class LethalFantasyUtils {
attackD30result,
attackD30message,
attackRerollContext,
attackNaturalRoll: msg.attackNaturalRoll,
damageTier: msg.damageTier,
defenderId: defender.id,
defenderTokenId,
isRanged: msg.isRanged
}
defender.system.prepareMonsterRoll("monster-defense", result)
await defender.system.prepareMonsterRoll("monster-defense", result)
}
return
}
@@ -397,6 +676,7 @@ export default class LethalFantasyUtils {
attackD30result,
attackD30message,
attackRerollContext,
damageTier: msg.damageTier,
defenderId: defender.id,
defenderTokenId,
isRanged: true
@@ -443,6 +723,8 @@ export default class LethalFantasyUtils {
content,
buttons: [
{
action: "defenseRoll",
type: "button",
label: "Roll Defense",
icon: "fa-solid fa-shield",
callback: (event, button, dialog) => {
@@ -467,16 +749,18 @@ export default class LethalFantasyUtils {
attackRollType,
attackRollKey,
attackD30result,
attackD30message,
attackRerollContext,
defenderId: defender.id,
attackD30message,
attackRerollContext,
attackNaturalRoll: msg.attackNaturalRoll,
damageTier: msg.damageTier,
defenderId: defender.id,
defenderTokenId,
isRanged: msg.isRanged
}
console.log("Storing defense data for character:", defender.id)
log("Storing defense data for character:", defender.id)
defender.prepareRoll("weapon-defense", result)
await defender.prepareRoll("weapon-defense", result)
}
}
@@ -485,6 +769,190 @@ export default class LethalFantasyUtils {
return d30Message?.type === "mulligan"
}
/* -------------------------------------------- */
/**
* Process D30 bonus dice for attack or defense.
* Rolls and applies bonus dice BEFORE grit/luck/shield decisions.
* For `choice` type results (D30=20, 30), shows dialog to choose between bonus dice and special effect.
* For `bonus_dice` type results (D30=27, 2, 3), auto-rolls the dice.
* @param {Object|null} d30Message The D30 result object
* @param {"attack"|"defense"} side Whether processing the attack or defense side
* @param {number|null} naturalRoll The natural D20 roll (for special strike type detection)
* @param {Object} actor The actor (for dice3d display)
* @returns {Promise<{modifier: number, specialEffect: string|null, specialName: string|null}>}
*/
static async processD30BonusDice(d30Message, side, naturalRoll = null, actor = null, canDialog = true) {
if (!d30Message) return { modifier: 0, specialEffect: null, specialName: null }
const validTargets = side === "attack" ? ["attack", "spell_attack"] : ["defense", "spell_defense"]
// ── Simple bonus_dice type ── auto-roll if target matches
if (d30Message.type === "bonus_dice") {
if (!validTargets.includes(d30Message.target)) return { modifier: 0, specialEffect: null, specialName: null }
const modifier = await this._rollD30BonusDie(d30Message.dice, actor, !canDialog)
return { modifier, specialEffect: null, specialName: null }
}
// ── Choice type ── present all options to the player
if (d30Message.type === "choice") {
// If we can't show dialogs (wrong client), skip — the primary client
// will communicate its choice result via socket. Auto-rolling here
// would give a different modifier on each client, causing divergence.
if (!canDialog) {
return { modifier: 0, specialEffect: null, specialName: null }
}
const buttons = d30Message.choices.map(c => {
let label
let icon
if (c.type === "bonus_dice") {
label = `Roll ${c.dice.toUpperCase()} and add to ${side}`
icon = "fa-solid fa-dice"
} else if (c.type === "special_strike") {
label = this._buildSpecialLabel(c, naturalRoll)
icon = "fa-solid fa-star"
} else if (c.type === "special_defense") {
label = this._buildSpecialLabel(c, naturalRoll)
icon = "fa-solid fa-shield-halved"
} else {
label = c.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())
icon = "fa-solid fa-question"
}
return {
action: c.type,
type: "button",
label,
icon,
callback: () => c
}
})
const choice = await foundry.applications.api.DialogV2.wait({
window: { title: "D30 Special — Choose Effect" },
classes: ["lethalfantasy"],
content: `
<div class="grit-luck-dialog">
<p><strong>D30 result:</strong> ${d30Message.description}</p>
<p>Choose how to use this result:</p>
</div>
`,
buttons,
rejectClose: false
})
if (!choice) return { modifier: 0, specialEffect: null, specialName: null }
if (choice.type === "bonus_dice") {
const modifier = await this._rollD30BonusDie(choice.dice, actor)
return { modifier, specialEffect: null, specialName: null }
}
if (choice.type === "special_strike" || choice.type === "special_defense") {
return { modifier: 0, specialEffect: "auto", specialName: this._buildSpecialName(choice, naturalRoll) }
}
// Non-standard choice (spell_calamity, etc.) — report it
return { modifier: 0, specialEffect: "flag", specialName: choice.type }
}
// ── Combo type (bleed / internal injury) — flag for wound creation
if (d30Message.type === "combo") {
const hasBleed = d30Message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury")
if (hasBleed) {
return { modifier: 0, specialEffect: "bleed", specialName: "Bleeding/Internal Injury" }
}
}
// ── Damage multiplier type (2x/3x damage before DR)
if (d30Message.type === "damage_multiplier") {
return { modifier: 0, specialEffect: "damageMultiplier", specialName: `x${d30Message.multiplier} Damage`, multiplier: d30Message.multiplier }
}
// ── DR multiplier type (2x/3x DR including shield)
if (d30Message.type === "dr_multiplier") {
return { modifier: 0, specialEffect: "drMultiplier", specialName: `x${d30Message.multiplier} DR`, multiplier: d30Message.multiplier }
}
return { modifier: 0, specialEffect: null, specialName: null }
}
/* -------------------------------------------- */
/**
* Roll a D30 bonus die and show with 3D dice if available.
* @param {string} formula Dice formula (e.g. "D6", "D12", "D20E")
* @param {Object} actor Actor for chat message speaker
* @returns {Promise<number>} The roll total
*/
static async _rollD30BonusDie(formula, actor, silent = false) {
const cleaned = formula.replace(/NE$/i, "").replace("E", "")
const roll = new Roll(cleaned)
await roll.evaluate()
if (game?.dice3d) {
await game.dice3d.showForRoll(roll, game.user, true)
}
if (!silent) {
await ChatMessage.create({
content: `<p>D30 bonus: rolled <strong>${cleaned.toUpperCase()}</strong> = <strong>${roll.total}</strong></p>`,
speaker: ChatMessage.getSpeaker({ actor })
})
}
return roll.total
}
/* -------------------------------------------- */
/**
* Build a human-readable label for a special strike/defense choice in the D30 prompt.
* @param {Object} specialChoice The choice object with type and options
* @param {number|null} naturalRoll The natural D20 roll
* @returns {string} Display label
*/
static _buildSpecialLabel(specialChoice, naturalRoll) {
if (specialChoice.type === "special_strike") {
if (specialChoice.options.includes("lethal")) {
if (naturalRoll === 20) return "Lethal Strike (auto-hit)"
if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike (auto-hit)"
return "Lethal/Vital Strike (auto-hit)"
}
if (specialChoice.options.includes("vicious")) return "Vicious Strike (auto-hit)"
return "Special Strike (auto-hit)"
}
if (specialChoice.type === "special_defense") {
if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense (auto-block)"
if (specialChoice.options.includes("flawless")) return "Flawless Defense (auto-block)"
if (specialChoice.options.includes("legendary")) return "Legendary Defense (auto-block)"
if (specialChoice.options.includes("perfect")) return "Perfect Defense (auto-block)"
return "Special Defense (auto-block)"
}
return "Special Effect"
}
/* -------------------------------------------- */
/**
* Build the special effect name based on the D30 result and natural roll.
* @param {Object} specialChoice The choice object with type and options
* @param {number|null} naturalRoll The natural D20 roll
* @returns {string} The special effect name
*/
static _buildSpecialName(specialChoice, naturalRoll) {
if (specialChoice.type === "special_strike") {
if (specialChoice.options.includes("lethal")) {
if (naturalRoll === 20) return "Lethal Strike"
if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike"
return "Lethal/Vital Strike"
}
if (specialChoice.options.includes("vicious")) return "Vicious Strike"
return "Special Strike"
}
if (specialChoice.type === "special_defense") {
if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense"
if (specialChoice.options.includes("flawless")) return "Flawless Defense"
if (specialChoice.options.includes("legendary")) return "Legendary Defense"
if (specialChoice.options.includes("perfect")) return "Perfect Defense"
return "Special Defense"
}
return "Special Effect"
}
/* -------------------------------------------- */
static getCombatBonusDiceChoices() {
return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"]
@@ -542,6 +1010,7 @@ export default class LethalFantasyUtils {
buttons: [
{
action: "roll",
type: "button",
label: "Roll Bonus Die",
icon: "fa-solid fa-dice",
callback: (event, button) => {
@@ -551,6 +1020,7 @@ export default class LethalFantasyUtils {
},
{
action: "cancel",
type: "button",
label: "Cancel",
icon: "fa-solid fa-xmark",
callback: () => null
@@ -598,6 +1068,7 @@ export default class LethalFantasyUtils {
buttons: [
{
action: "roll",
type: "button",
label: "Roll Shield",
icon: "fa-solid fa-shield",
callback: (event, button) => {
@@ -611,6 +1082,7 @@ export default class LethalFantasyUtils {
},
{
action: "cancel",
type: "button",
label: "Cancel",
icon: "fa-solid fa-xmark",
callback: () => null
@@ -681,6 +1153,7 @@ export default class LethalFantasyUtils {
if (currentGrit > 0) {
buttons.push({
action: "grit",
type: "button",
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
icon: "fa-solid fa-fist-raised",
callback: () => "grit"
@@ -690,6 +1163,7 @@ export default class LethalFantasyUtils {
if (currentLuck > 0) {
buttons.push({
action: "luck",
type: "button",
label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
icon: "fa-solid fa-clover",
callback: () => "luck"
@@ -698,6 +1172,7 @@ export default class LethalFantasyUtils {
buttons.push({
action: "continue",
type: "button",
label: "Continue (no bonus)",
icon: "fa-solid fa-forward",
callback: () => "continue"
@@ -773,12 +1248,14 @@ export default class LethalFantasyUtils {
const buttons = [
{
action: "grit",
type: "button",
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
icon: "fa-solid fa-fist-raised",
callback: () => "grit"
},
{
action: "continue",
type: "button",
label: "Continue (no bonus)",
icon: "fa-solid fa-forward",
callback: () => "continue"
@@ -832,30 +1309,39 @@ export default class LethalFantasyUtils {
/* -------------------------------------------- */
static async compareAttackDefense(data) {
console.log("compareAttackDefense called with:", data)
log("compareAttackDefense called with:", data)
// Compute D30 effects from the attack D30 message directly.
// This is more reliable than depending on the caller-provided values, which are
// computed per-client and may differ between clients due to cross-client processing order.
const d30DamageMultiplier = data.attackD30message?.type === "damage_multiplier"
? data.attackD30message.multiplier
: (data.d30DamageMultiplier || 1)
const d30Bleed = data.attackD30message?.type === "combo"
? (data.attackD30message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury") ? "true" : "")
: data.attackD30message?.type === "bleed" ? "true" : (data.d30Bleed || "")
const d30DrMultiplier = data.d30DrMultiplier || 1
const outcome = data.outcome || (data.attackRoll > data.defenseRoll ? "hit" : "miss")
const isAttackWin = outcome !== "miss"
console.log("isAttackWin:", isAttackWin, "attackRoll:", data.attackRoll, "defenseRoll:", data.defenseRoll)
log("isAttackWin:", isAttackWin, "attackRoll:", data.attackRoll, "defenseRoll:", data.defenseRoll)
let damageButton = ""
if (isAttackWin && (data.attackWeaponId || data.attackRollKey)) {
console.log("Creating damage button. defenderId:", data.defenderId)
log("Creating damage button. defenderId:", data.defenderId)
// Déterminer le type de dégâts à lancer
if (data.attackRollType === "weapon-attack") {
damageButton = `
<div class="attack-result-damage">
<button class="roll-damage-btn" data-attacker-id="${data.attackerId}" data-defender-id="${data.defenderId}" data-defender-token-id="${data.defenderTokenId || ""}" data-extra-shield-dr="${data.shieldDamageReduction || 0}" data-weapon-id="${data.attackWeaponId}" data-damage-type="small">
<i class="fa-solid fa-dice-d6"></i> Damage (Small)
</button>
<button class="roll-damage-btn" data-attacker-id="${data.attackerId}" data-defender-id="${data.defenderId}" data-defender-token-id="${data.defenderTokenId || ""}" data-extra-shield-dr="${data.shieldDamageReduction || 0}" data-weapon-id="${data.attackWeaponId}" data-damage-type="medium">
<i class="fa-solid fa-dice-d20"></i> Damage (Medium)
<div class="attack-result-damage single-btn">
<button class="roll-damage-btn" data-attacker-id="${data.attackerId}" data-defender-id="${data.defenderId}" data-defender-token-id="${data.defenderTokenId || ""}" data-extra-shield-dr="${data.shieldDamageReduction || 0}" data-weapon-id="${data.attackWeaponId}" data-damage-type="medium" data-d30-bleed="${d30Bleed}" data-d30-damage-mult="${d30DamageMultiplier}" data-d30-dr-mult="${d30DrMultiplier}">
<i class="fa-solid fa-dice-d20"></i> Damage
</button>
</div>
`
} else if (data.attackRollType === "monster-attack") {
damageButton = `
<div class="attack-result-damage single-btn">
<button class="roll-damage-btn" data-attacker-id="${data.attackerId}" data-defender-id="${data.defenderId}" data-defender-token-id="${data.defenderTokenId || ""}" data-extra-shield-dr="${data.shieldDamageReduction || 0}" data-attack-key="${data.attackRollKey}" data-damage-type="monster">
<button class="roll-damage-btn" data-attacker-id="${data.attackerId}" data-defender-id="${data.defenderId}" data-defender-token-id="${data.defenderTokenId || ""}" data-extra-shield-dr="${data.shieldDamageReduction || 0}" data-attack-key="${data.attackRollKey}" data-damage-type="monster" data-d30-bleed="${d30Bleed}" data-d30-damage-mult="${d30DamageMultiplier}" data-d30-dr-mult="${d30DrMultiplier}">
<i class="fa-solid fa-burst"></i> Damage
</button>
</div>
@@ -863,11 +1349,13 @@ export default class LethalFantasyUtils {
} else if (data.attackRollType === "spell-attack" || data.attackRollType === "miracle-attack") {
const attacker = game.actors.get(data.attackerId)
const spell = attacker?.items.get(data.attackWeaponId || data.attackRollKey)
const tiers = [
{ formula: spell?.system?.damageDice, label: "Standard" },
{ formula: spell?.system?.damageDiceOverpowered, label: "Overpowered" },
{ formula: spell?.system?.damageDiceOverpowered2, label: "Overpowered 2" },
].filter(t => t.formula)
const chosenTier = data.damageTier || "standard"
const allTiers = [
{ id: "standard", formula: spell?.system?.damageDice, label: "Standard" },
{ id: "overpowered", formula: spell?.system?.damageDiceOverpowered, label: "Overpowered" },
{ id: "overpowered2", formula: spell?.system?.damageDiceOverpowered2, label: "Overpowered 2" },
]
const tiers = allTiers.filter(t => t.id === chosenTier && t.formula)
if (tiers.length) {
const buttons = tiers.map(t => {
const escapedFormula = Handlebars.escapeExpression(t.formula)
@@ -877,7 +1365,10 @@ export default class LethalFantasyUtils {
data-defender-id="${data.defenderId}"
data-defender-token-id="${data.defenderTokenId || ""}"
data-damage-type="spell"
data-damage-formula="${escapedFormula}">
data-damage-formula="${escapedFormula}"
data-d30-bleed="${d30Bleed}"
data-d30-damage-mult="${d30DamageMultiplier}"
data-d30-dr-mult="${d30DrMultiplier}">
<i class="fa-solid fa-wand-magic-sparkles"></i> ${t.label} (${escapedFormula})
</button>`
}).join("")
@@ -918,12 +1409,12 @@ export default class LethalFantasyUtils {
</div>
`
console.log("Creating combat result message...")
log("Creating combat result message...")
await ChatMessage.create({
content: resultMessage,
speaker: { alias: "Combat System" }
})
console.log("Combat result message created!")
log("Combat result message created!")
}
static registerHandlebarsHelpers() {
@@ -1060,7 +1551,7 @@ export default class LethalFantasyUtils {
return eval(expr);
})
Handlebars.registerHelper('isOwnerOrGM', function (actor) {
console.log("Testing actor", actor.isOwner, game.userId)
log("Testing actor", actor.isOwner, game.userId)
return actor.isOwner || game.isGM;
})
Handlebars.registerHelper('upperCase', function (text) {
@@ -1155,21 +1646,25 @@ export default class LethalFantasyUtils {
buttons: [
{
action: "noDR",
type: "button",
label: "No DR",
callback: () => ({ drType: "none", damage: damageTotal })
},
{
action: "armorDR",
type: "button",
label: "With Armor DR",
callback: () => ({ drType: "armor", damage: Math.max(0, damageTotal - armorDR) })
},
{
action: "allDR",
type: "button",
label: "With Armor + Shield DR",
callback: () => ({ drType: "all", damage: Math.max(0, damageTotal - totalDR) })
},
{
action: "cancel",
type: "button",
label: "Cancel",
callback: () => null
}
@@ -1198,7 +1693,7 @@ export default class LethalFantasyUtils {
}
)
ChatMessage.create({
await ChatMessage.create({
user: game.user.id,
speaker: { alias: targetActor.name },
mode: "gm",
-1
View File
@@ -1 +0,0 @@
MANIFEST-000611
View File
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.153266 7f179e7ed6c0 Recovering log #609
2026/05/20-23:09:33.205459 7f179e7ed6c0 Delete type=3 #607
2026/05/20-23:09:33.205507 7f179e7ed6c0 Delete type=0 #609
2026/05/20-23:16:53.517842 7f179d7eb6c0 Level-0 table #614: started
2026/05/20-23:16:53.517893 7f179d7eb6c0 Level-0 table #614: 0 bytes OK
2026/05/20-23:16:53.523991 7f179d7eb6c0 Delete type=0 #612
2026/05/20-23:16:53.536989 7f179d7eb6c0 Manual compaction at level-0 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.537026 7f179d7eb6c0 Manual compaction at level-1 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.079181 7f0b1e7ee6c0 Recovering log #605
2026/05/20-00:10:37.089613 7f0b1e7ee6c0 Delete type=3 #603
2026/05/20-00:10:37.089671 7f0b1e7ee6c0 Delete type=0 #605
2026/05/20-10:53:40.566713 7f0b1cfeb6c0 Level-0 table #610: started
2026/05/20-10:53:40.566740 7f0b1cfeb6c0 Level-0 table #610: 0 bytes OK
2026/05/20-10:53:40.572741 7f0b1cfeb6c0 Delete type=0 #608
2026/05/20-10:53:40.587356 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.587409 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
Binary file not shown.
View File
-1
View File
@@ -1 +0,0 @@
MANIFEST-000608
View File
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.234730 7f179e7ed6c0 Recovering log #606
2026/05/20-23:09:33.288868 7f179e7ed6c0 Delete type=3 #604
2026/05/20-23:09:33.288920 7f179e7ed6c0 Delete type=0 #606
2026/05/20-23:16:53.524172 7f179d7eb6c0 Level-0 table #611: started
2026/05/20-23:16:53.524219 7f179d7eb6c0 Level-0 table #611: 0 bytes OK
2026/05/20-23:16:53.530312 7f179d7eb6c0 Delete type=0 #609
2026/05/20-23:16:53.537003 7f179d7eb6c0 Manual compaction at level-0 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.537059 7f179d7eb6c0 Manual compaction at level-1 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.107483 7f0b1efef6c0 Recovering log #602
2026/05/20-00:10:37.117907 7f0b1efef6c0 Delete type=3 #600
2026/05/20-00:10:37.117968 7f0b1efef6c0 Delete type=0 #602
2026/05/20-10:53:40.572909 7f0b1cfeb6c0 Level-0 table #607: started
2026/05/20-10:53:40.572948 7f0b1cfeb6c0 Level-0 table #607: 0 bytes OK
2026/05/20-10:53:40.579001 7f0b1cfeb6c0 Delete type=0 #605
2026/05/20-10:53:40.587370 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.587430 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
Binary file not shown.
View File
-1
View File
@@ -1 +0,0 @@
MANIFEST-000613
View File
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.082007 7f179dfec6c0 Recovering log #611
2026/05/20-23:09:33.140910 7f179dfec6c0 Delete type=3 #609
2026/05/20-23:09:33.140962 7f179dfec6c0 Delete type=0 #611
2026/05/20-23:16:53.510592 7f179d7eb6c0 Level-0 table #616: started
2026/05/20-23:16:53.510664 7f179d7eb6c0 Level-0 table #616: 0 bytes OK
2026/05/20-23:16:53.517661 7f179d7eb6c0 Delete type=0 #614
2026/05/20-23:16:53.536973 7f179d7eb6c0 Manual compaction at level-0 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.537037 7f179d7eb6c0 Manual compaction at level-1 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.061485 7f0b1efef6c0 Recovering log #607
2026/05/20-00:10:37.071572 7f0b1efef6c0 Delete type=3 #605
2026/05/20-00:10:37.071684 7f0b1efef6c0 Delete type=0 #607
2026/05/20-10:53:40.579189 7f0b1cfeb6c0 Level-0 table #612: started
2026/05/20-10:53:40.579229 7f0b1cfeb6c0 Level-0 table #612: 0 bytes OK
2026/05/20-10:53:40.587176 7f0b1cfeb6c0 Delete type=0 #610
2026/05/20-10:53:40.587386 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.587419 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
MANIFEST-000308
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.368596 7f179e7ed6c0 Recovering log #306
2026/05/20-23:09:33.422152 7f179e7ed6c0 Delete type=3 #304
2026/05/20-23:09:33.422210 7f179e7ed6c0 Delete type=0 #306
2026/05/20-23:16:53.560903 7f179d7eb6c0 Level-0 table #311: started
2026/05/20-23:16:53.560969 7f179d7eb6c0 Level-0 table #311: 0 bytes OK
2026/05/20-23:16:53.568455 7f179d7eb6c0 Delete type=0 #309
2026/05/20-23:16:53.568616 7f179d7eb6c0 Manual compaction at level-0 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.578679 7f179d7eb6c0 Manual compaction at level-1 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.136939 7f0b1efef6c0 Recovering log #302
2026/05/20-00:10:37.146957 7f0b1efef6c0 Delete type=3 #300
2026/05/20-00:10:37.147030 7f0b1efef6c0 Delete type=0 #302
2026/05/20-10:53:40.615430 7f0b1cfeb6c0 Level-0 table #307: started
2026/05/20-10:53:40.615467 7f0b1cfeb6c0 Level-0 table #307: 0 bytes OK
2026/05/20-10:53:40.621586 7f0b1cfeb6c0 Delete type=0 #305
2026/05/20-10:53:40.621713 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.633723 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
MANIFEST-000607
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.302872 7f179efee6c0 Recovering log #605
2026/05/20-23:09:33.362229 7f179efee6c0 Delete type=3 #603
2026/05/20-23:09:33.362319 7f179efee6c0 Delete type=0 #605
2026/05/20-23:16:53.530498 7f179d7eb6c0 Level-0 table #610: started
2026/05/20-23:16:53.530547 7f179d7eb6c0 Level-0 table #610: 0 bytes OK
2026/05/20-23:16:53.536854 7f179d7eb6c0 Delete type=0 #608
2026/05/20-23:16:53.537015 7f179d7eb6c0 Manual compaction at level-0 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.537048 7f179d7eb6c0 Manual compaction at level-1 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.121340 7f0b1e7ee6c0 Recovering log #601
2026/05/20-00:10:37.132435 7f0b1e7ee6c0 Delete type=3 #599
2026/05/20-00:10:37.132491 7f0b1e7ee6c0 Delete type=0 #601
2026/05/20-10:53:40.559937 7f0b1cfeb6c0 Level-0 table #606: started
2026/05/20-10:53:40.560028 7f0b1cfeb6c0 Level-0 table #606: 0 bytes OK
2026/05/20-10:53:40.566605 7f0b1cfeb6c0 Delete type=0 #604
2026/05/20-10:53:40.587338 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.587398 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
Binary file not shown.
+4 -4
View File
@@ -387,11 +387,11 @@
min-width: 6rem;
max-width: 6rem;
}
min-width: 10rem;
max-width: 10rem;
min-width: 11rem;
max-width: 11rem;
.input {
min-width: 2.5rem;
max-width: 2.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
}
}
.granted {
+60
View File
@@ -89,6 +89,66 @@
font-size: 0.9rem;
}
/* Luck/Grit Styles */
#token-hud .luck-grit-wrap {
position: absolute;
left: 75px;
display: none;
top: 50%;
width: 80px;
text-align: start;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(139, 69, 19, 0.5);
border-radius: 4px;
padding: 4px 6px;
transform: translate(-100%, -50%);
}
#token-hud .luck-grit-hud-active {
display: block;
}
#token-hud .luck-grit-hud-disabled {
display: none;
}
#token-hud .luck-grit-row {
display: flex;
align-items: center;
gap: 4px;
margin: 2px 0;
}
#token-hud .luck-grit-label {
flex: 1;
color: #c9b896;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
#token-hud .luck-grit-btn {
width: 28px;
height: 22px;
padding: 0;
background: rgba(139, 69, 19, 0.25);
border: 1px solid rgba(139, 69, 19, 0.4);
border-radius: 3px;
color: #d4c5a9;
font-size: 12px;
font-weight: 700;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: rgba(139, 69, 19, 0.5);
border-color: rgba(139, 69, 19, 0.7);
}
}
/* -------------------------------------------------- */
/* Dice Tray — injected into the Foundry chat sidebar */
/* -------------------------------------------------- */
+4 -7
View File
@@ -647,15 +647,11 @@
.damage-buttons-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: 1fr;
max-width: 280px;
margin: 0 auto;
gap: 8px;
&.monster-damage {
grid-template-columns: 1fr;
max-width: 280px;
margin: 0 auto;
}
.damage-roll-btn {
padding: 10px 14px;
background: linear-gradient(to bottom, #8b4513 0%, #6b3410 100%);
@@ -847,6 +843,7 @@
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
justify-items: center;
}
}
}
+4 -10
View File
@@ -87,16 +87,10 @@
<i class="fa-solid fa-shield-halved" data-roll-type="weapon-defense" data-roll-key="{{item.id}}"></i>
</a>
<a class="rollable" data-roll-type="weapon-damage-small" data-roll-key="{{item.id}}"
data-tooltip="Roll Damage (Small)">
<i class="fa-regular fa-face-head-bandage" data-roll-type="weapon-damage-small"
data-roll-key="{{item.id}}"></i>S
</a>
<a class="rollable" data-roll-type="weapon-damage-medium" data-roll-key="{{item.id}}"
data-tooltip="Roll Damage (Medium)">
<i class="fa-regular fa-face-head-bandage" data-roll-type="weapon-damage-medium"
data-roll-key="{{item.id}}"></i>M
<a class="rollable" data-roll-type="weapon-damage" data-roll-key="{{item.id}}"
data-tooltip="Roll Damage">
<i class="fa-regular fa-face-head-bandage" data-roll-type="weapon-damage"
data-roll-key="{{item.id}}"></i>
</a>
</div>
+3 -22
View File
@@ -69,16 +69,10 @@
</div>
{{/if}}
{{#if rollData.damageSmall}}
<div class="detail-badge damage-badge">
<i class="fa-solid fa-dice-d6"></i>
<span>{{localize "LETHALFANTASY.Label.weapon-damage-small"}}</span>
</div>
{{/if}}
{{#if rollData.damageMedium}}
{{#if rollData.isDamage}}
<div class="detail-badge damage-badge">
<i class="fa-solid fa-dice-d20"></i>
<span>{{localize "LETHALFANTASY.Label.weapon-damage-medium"}}</span>
<span>{{localize "LETHALFANTASY.Label.weapon-damage"}}</span>
</div>
{{/if}}
@@ -202,19 +196,6 @@
}}+{{weaponDamageOptions.damageModifier}}{{/if}}
</button>
{{else}}
{{#if weaponDamageOptions.damageS}}
<button
class="damage-roll-btn"
data-weapon-id="{{weaponDamageOptions.weaponId}}"
data-damage-type="small"
data-damage-formula="{{weaponDamageOptions.damageS}}"
data-is-monster="false"
title="{{localize 'LETHALFANTASY.Label.rollDamage'}}"
>
<i class="fa-solid fa-dice-d6"></i>
Damage Small
</button>
{{/if}}
{{#if weaponDamageOptions.damageM}}
<button
class="damage-roll-btn"
@@ -225,7 +206,7 @@
title="{{localize 'LETHALFANTASY.Label.rollDamage'}}"
>
<i class="fa-solid fa-dice-d20"></i>
Damage Medium
{{localize "LETHALFANTASY.Label.damage"}}
</button>
{{/if}}
{{/if}}
+16
View File
@@ -0,0 +1,16 @@
<div class="control-icon lethal-luck-grit-hud" data-action="lethal-luck-grit-hud">
<i class="fa-solid fa-dice" title="Adjust Luck/Grit" style="width:36px;height:36px;display:flex;align-items:center;justify-content:center;font-size:18px;cursor:pointer"></i>
<div class="luck-grit-wrap luck-grit-hud-disabled">
<div class="luck-grit-row">
<span class="luck-grit-label">Luck</span>
<button class="luck-grit-btn" data-resource="luck" data-amount="-1">1</button>
<button class="luck-grit-btn" data-resource="luck" data-amount="1">+1</button>
</div>
<div class="luck-grit-row">
<span class="luck-grit-label">Grit</span>
<button class="luck-grit-btn" data-resource="grit" data-amount="-1">1</button>
<button class="luck-grit-btn" data-resource="grit" data-amount="1">+1</button>
</div>
</div>
</div>
+45
View File
@@ -0,0 +1,45 @@
<div class="lethalfantasy-range-defense-dialog">
<fieldSet class="">
<legend>{{localize "LETHALFANTASY.Label.rangeAttackDialog"}}</legend>
<div class="field-section">
<span class="field-name">Attacker Movement :</span>
<select name="movement" data-tooltip-direction="UP">
{{selectOptions attackerMovementChoices selected=movement}}
</select>
</div>
<div class="field-section">
<span class="field-name">Range :</span>
<select name="range" data-tooltip-direction="UP">
{{selectOptions rangeChoices selected=range}}
</select>
</div>
<div class="field-section">
<span class="field-name">Target Size :</span>
<select name="size" data-tooltip-direction="UP">
{{selectOptions sizeChoices selected=size}}
</select>
</div>
<div class="field-section">
<span class="field-name">Aim :</span>
<select name="attackerAim" data-tooltip-direction="UP">
{{selectOptions attackerAimChoices selected=attackerAim}}
</select>
</div>
</fieldSet>
<fieldSet>
<legend>{{localize "LETHALFANTASY.Roll.visibility"}}</legend>
<span class="fieldset-centered">
<select name="visibility">
{{selectOptions rollModes selected=visibility localize=true}}
</select>
</span>
</fieldSet>
</div>
+37 -40
View File
@@ -55,35 +55,33 @@
<div class="dialog-save">Add Granted Attack Dice
<input type="checkbox" data-action="selectGranted" name="granted" />
</div>
{{#if rollTarget.weapon}}
{{#if (eq rollTarget.weapon.system.weaponType "melee")}}{{else}}
<div class="dialog-save">Point Blank Range Attack
<input
type="checkbox"
data-action="selectPointBlank"
name="pointBlankV"
/>
</div>
<div class="dialog-save">Beyond Skill Range Attack
<input
type="checkbox"
data-action="selectBeyondSkill"
name="beyondSkillV"
/>
</div>
<div class="dialog-save">Let it Fly (Pure D20E)
<input
type="checkbox"
data-action="selectLetItFly"
name="letItFlyV"
/>
</div>
<div class="dialog-save">Aiming
<select name="attackerAim" data-tooltip-direction="UP">
{{selectOptions attackerAimChoices selected=attackerAim}}
</select>
</div>
{{/if}}
{{#if isRangedAttack}}
<div class="dialog-save">Point Blank Range Attack
<input
type="checkbox"
data-action="selectPointBlank"
name="pointBlankV"
/>
</div>
<div class="dialog-save">Beyond Skill Range Attack
<input
type="checkbox"
data-action="selectBeyondSkill"
name="beyondSkillV"
/>
</div>
<div class="dialog-save">Let it Fly (Pure D20E)
<input
type="checkbox"
data-action="selectLetItFly"
name="letItFlyV"
/>
</div>
<div class="dialog-save">Aiming
<select name="attackerAim" data-tooltip-direction="UP">
{{selectOptions attackerAimChoices selected=attackerAim}}
</select>
</div>
{{/if}}
{{/if}}
@@ -125,17 +123,16 @@
</select>
{{#if (eq rollType "save")}}
{{#if rollTarget.magicUser}}
<div>
<span>Save against spell (+{{rollTarget.actorModifiers.saveModifier}})
?</span>
<input
type="checkbox"
name="saveSpellCheck"
data-action="saveSpellCheck"
/>
</div>
{{/if}}
<div>
<span>Save against spell (+{{rollTarget.actorModifiers.saveModifier}})
?</span>
<input
type="checkbox"
name="saveSpellCheck"
data-action="saveSpellCheck"
{{#if saveSpell}}checked{{/if}}
/>
</div>
{{/if}}
</fieldSet>
{{/if}}
+2
View File
@@ -6,6 +6,8 @@
{{formField systemFields.level value=system.level}}
{{formField systemFields.cost value=system.cost}}
{{formField systemFields.costOverpowered value=system.costOverpowered}}
{{formField systemFields.costOverpowered2 value=system.costOverpowered2}}
<label>{{localize "LETHALFANTASY.Label.components"}}</label>
<div class="shift-right">
+1 -5
View File
@@ -17,11 +17,7 @@
{{formField systemFields.damageType.fields.typeS value=system.damageType.typeS}}
</div>
<label>{{localize "LETHALFANTASY.Label.damage"}}</label>
<div class="shift-right">
{{formField systemFields.damage.fields.damageS value=system.damage.damageS}}
{{formField systemFields.damage.fields.damageM value=system.damage.damageM}}
</div>
{{formField systemFields.damage.fields.damageM value=system.damage.damageM label=(localize "LETHALFANTASY.Label.damage")}}
{{formField systemFields.applyStrengthDamageBonus value=system.applyStrengthDamageBonus localize=true}}
+2
View File
@@ -0,0 +1,2 @@
import { CompendiumsManager } from './CompendiumsManager.mjs';
CompendiumsManager.packToDistDir('packs_src', 'packs-system');
+2
View File
@@ -0,0 +1,2 @@
import { CompendiumsManager } from './CompendiumsManager.mjs';
CompendiumsManager.unpackToSrcDir('packs_src', 'packs-system');