Compare commits

...

30 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
65 changed files with 1946 additions and 870 deletions
+1
View File
@@ -0,0 +1 @@
packs/** filter=lfs diff=lfs merge=lfs -text
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
token: ${{ secrets.FOUNDRY_PUBLISH_KEY }} token: ${{ secrets.FOUNDRY_PUBLISH_KEY }}
id: "fvtt-lethal-fantasy" id: "fvtt-lethal-fantasy"
version: ${{github.event.release.tag_name}} 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" notes: "https://www.uberwald.me/gitea/public/fvtt-lethal-fantasy/raw/branch/main/changelog.md"
compatibility-minimum: "14" compatibility-minimum: "14"
compatibility-verified: "14" compatibility-verified: "14"
+11
View File
@@ -9,3 +9,14 @@ node_modules/
.history .history
.github/ .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
-62
View File
@@ -1,62 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with this repository.
## Overview
FoundryVTT v13+ game system for the **Lethal Fantasy RPG**. Entry point: `lethal-fantasy.mjs`.
## Commands
```bash
# Compile LESS styles (styles/ -> css/)
npx gulp css # one-shot
npx gulp # compile + watch
# Lint
npx eslint .
# Compendium pack management (LevelDB <-> YAML source)
npm run pushLDBtoYML # export packs-system/ LevelDB -> source YAML
npm run pullYMLtoLDB # import source YAML -> packs-system/ LevelDB
```
No test suite exists.
## Architecture
Four layers in `module/`, all wired in `lethal-fantasy.mjs` via the `init` hook:
| Layer | Path | Purpose |
|---|---|---|
| Config | `module/config/` | Game constants. `SYSTEM` is `globalThis.SYSTEM` — always use `SYSTEM.*` for enumerations. |
| Models | `module/models/` | `TypeDataModel` subclasses — data schemas per document type. |
| Documents | `module/documents/` | Actor/Item/Roll/ChatMessage subclasses — game logic, roll processing, hooks. |
| Applications | `module/applications/` | `ApplicationV2` sheets + custom combat tracker. |
**Actor types**: `character`, `monster`
**Item types**: `skill`, `gift`, `vulnerability`, `weapon`, `armor`, `shield`, `spell`, `miracle`, `equipment`
Each layer has an `_module.mjs` barrel file that re-exports all classes from that layer.
Templates (`.hbs`) live in `templates/`. Styles are authored in LESS under `styles/` and compiled to `css/`.
### Key Patterns
- **Sheets**: Extend `HandlebarsApplicationMixin(foundry.applications.sheets.ActorSheetV2)` — imported from `foundry.applications.api`. **Not** the legacy `ActorSheet`. Child sheets (e.g. `character-sheet.mjs`) extend `base-actor-sheet.mjs` and override `static PARTS` and `DEFAULT_OPTIONS.actions`. Template paths are prefixed `systems/fvtt-lethal-fantasy/templates/`. Actor sheets have a play/edit toggle via `_sheetMode` and `SHEET_MODES`.
- **Models**: `static defineSchema()` using `foundry.data.fields.*`. Field definitions derived from SYSTEM config objects.
- **Rolls**: `LethalFantasyRoll` extends `Roll` with rich metadata via `this.options`. `D30Roll` is a separate class for D30 result tables (initialized in the `ready` hook).
- **Socket**: Events use `game.socket.on(\`system.${SYSTEM.id}\`, ...)`. Multi-player attack-defense uses a global `pendingDefenses` Map.
- **i18n**: All user-visible strings are keys in `lang/en.json` as `LETHALFANTASY.Category.Key`. Always use `game.i18n.localize(key)`.
### Compendium Packs
Five LevelDB packs in `packs-system/`: skills, equipment, gifts, vulnerabilities, spells-miracles. Use the `tools/` scripts to export/import editable YAML.
## Code Style
- No semicolons, double quotes, 2-space indent
- JSDoc `/** */` required on all functions/classes
- Max line length 180 (strings/templates exempt)
- Arrow functions: omit parens for single param
- ESLint + Prettier config in `eslint.config.mjs`
+59 -11
View File
@@ -529,16 +529,16 @@ i.lethalfantasy {
} }
.lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .armor-hp { .lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .armor-hp {
margin-right: 4px; margin-right: 4px;
min-width: 10rem; min-width: 11rem;
max-width: 10rem; max-width: 11rem;
} }
.lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .armor-hp .name { .lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .armor-hp .name {
min-width: 6rem; min-width: 6rem;
max-width: 6rem; max-width: 6rem;
} }
.lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .armor-hp .input { .lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .armor-hp .input {
min-width: 2.5rem; min-width: 3.5rem;
max-width: 2.5rem; max-width: 3.5rem;
} }
.lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .granted { .lethalfantasy .tab.character-combat .main-div .combat-details .combat-detail .granted {
min-width: 8rem; min-width: 8rem;
@@ -3280,16 +3280,10 @@ i.lethalfantasy {
.message.lethalfantasy.dice-roll .damage-buttons .damage-buttons-grid, .message.lethalfantasy.dice-roll .damage-buttons .damage-buttons-grid,
.message.fvtt-lethal-fantasy.dice-roll .damage-buttons .damage-buttons-grid { .message.fvtt-lethal-fantasy.dice-roll .damage-buttons .damage-buttons-grid {
display: 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; grid-template-columns: 1fr;
max-width: 280px; max-width: 280px;
margin: 0 auto; margin: 0 auto;
gap: 8px;
} }
.lethalfantasy.dice-roll .damage-buttons .damage-buttons-grid .damage-roll-btn, .lethalfantasy.dice-roll .damage-buttons .damage-buttons-grid .damage-roll-btn,
.fvtt-lethal-fantasy.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; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 8px; gap: 8px;
justify-items: center;
} }
.lethalfantasy.dice-roll .d30-message, .lethalfantasy.dice-roll .d30-message,
.fvtt-lethal-fantasy.dice-roll .d30-message, .fvtt-lethal-fantasy.dice-roll .d30-message,
@@ -4139,6 +4134,59 @@ i.lethalfantasy {
padding-left: 8px; padding-left: 8px;
font-size: 0.9rem; 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 */ /* Dice Tray — injected into the Foundry chat sidebar */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
+34 -4
View File
@@ -1,8 +1,32 @@
{ {
"COMBAT": { "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}", "Round": "Second {round}",
"RoundNext": "Next second",
"RoundPrev": "Previous second",
"Rounds": "Seconds", "Rounds": "Seconds",
"RoundNext": "Next second" "Settings": "Combat Settings",
"ToggleDead": "Toggle Dead",
"ToggleVis": "Toggle Visible",
"TurnEnd": "End Turn",
"TurnNext": "Next Turn",
"TurnPrev": "Previous Turn"
}, },
"LETHALFANTASY": { "LETHALFANTASY": {
"Armor": { "Armor": {
@@ -505,8 +529,6 @@
"monster-defense": "Monster defense", "monster-defense": "Monster defense",
"weapons": "Weapons", "weapons": "Weapons",
"wis": "WIS", "wis": "WIS",
"weapon-damage-medium": "Weapon damage medium",
"weapon-damage-small": "Weapon damage small",
"combatProgressionStart": "Combat start threshold", "combatProgressionStart": "Combat start threshold",
"miracle": "Miracle", "miracle": "Miracle",
"titleStandard": "Standard Roll", "titleStandard": "Standard Roll",
@@ -626,7 +648,9 @@
"messageLethargyKO": "{spellName} : Lethargy still ongoing ... ( dice result : {roll} )", "messageLethargyKO": "{spellName} : Lethargy still ongoing ... ( dice result : {roll} )",
"messageProgressionKO": "{name} can't attack this second.", "messageProgressionKO": "{name} can't attack this second.",
"messageProgressionOKMonster": "{name} can attack this second with {weapon}.", "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": { "Opponent": {
"FIELDS": {} "FIELDS": {}
@@ -828,6 +852,12 @@
"cost": { "cost": {
"label": "Cost" "label": "Cost"
}, },
"costOverpowered": {
"label": "Cost (Overpowered)"
},
"costOverpowered2": {
"label": "Cost (Overpowered 2)"
},
"description": { "description": {
"label": "Description" "label": "Description"
}, },
+471 -108
View File
@@ -4,7 +4,6 @@
*/ */
import { SYSTEM } from "./module/config/system.mjs" import { SYSTEM } from "./module/config/system.mjs"
globalThis.SYSTEM = SYSTEM // Expose the SYSTEM object to the global scope
// Import modules // Import modules
import * as models from "./module/models/_module.mjs" import * as models from "./module/models/_module.mjs"
@@ -14,15 +13,24 @@ import * as applications from "./module/applications/_module.mjs"
import { LethalFantasyCombatTracker, LethalFantasyCombat } from "./module/applications/combat.mjs" import { LethalFantasyCombatTracker, LethalFantasyCombat } from "./module/applications/combat.mjs"
import { Macros } from "./module/macros.mjs" import { Macros } from "./module/macros.mjs"
import { setupTextEnrichers } from "./module/enrichers.mjs" import { setupTextEnrichers } from "./module/enrichers.mjs"
import { default as LethalFantasyUtils } from "./module/utils.mjs" import LethalFantasyUtils, { log } from "./module/utils.mjs"
export class ClassCounter { static printHello() { console.log("Hello") } static sendJsonPostRequest(e, s) { const t = { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify(s) }; return fetch(e, t).then((e => { if (!e.ok) throw new Error("La requête a échoué avec le statut " + e.status); return e.json() })).catch((e => { throw console.error("Erreur envoi de la requête:", e), e })) } static registerUsageCount(e = game.system.id, s = {}) { if (game.user.isGM) { game.settings.register(e, "world-key", { name: "Unique world key", scope: "world", config: !1, default: "", type: String }); let t = game.settings.get(e, "world-key"); null != t && "" != t && "NONE" != t && "none" != t.toLowerCase() || (t = foundry.utils.randomID(32), game.settings.set(e, "world-key", t)); let a = { name: e, system: game.system.id, worldKey: t, version: game.system.version, language: game.settings.get("core", "language"), remoteAddr: game.data.addresses.remote, nbInstalledModules: game.modules.size, nbActiveModules: game.modules.filter((e => e.active)).length, nbPacks: game.world.packs.size, nbUsers: game.users.size, nbScenes: game.scenes.size, nbActors: game.actors.size, nbPlaylist: game.playlists.size, nbTables: game.tables.size, nbCards: game.cards.size, optionsData: s, foundryVersion: `${game.release.generation}.${game.release.build}` }; this.sendJsonPostRequest("https://www.uberwald.me/fvtt_appcount/count_post.php", a) } } }
Hooks.once("init", function () { Hooks.once("init", function () {
globalThis.SYSTEM = SYSTEM
globalThis.pendingDefenses = new Map()
console.info("Lethal Fantasy RPG | Initializing System") console.info("Lethal Fantasy RPG | Initializing System")
console.info(SYSTEM.ASCII) console.info(SYSTEM.ASCII)
game.settings.register(game.system.id, "debug", {
name: "Debug logging",
scope: "client",
config: true,
default: false,
type: Boolean,
})
globalThis.lethalFantasy = game.system globalThis.lethalFantasy = game.system
globalThis.log = log
game.system.CONST = SYSTEM game.system.CONST = SYSTEM
// Expose the system API // Expose the system API
@@ -54,12 +62,10 @@ Hooks.once("init", function () {
miracle: models.LethalFantasyMiracle miracle: models.LethalFantasyMiracle
} }
// Register sheet application classes // Register sheet application classes (V2)
foundry.documents.collections.Actors.unregisterSheet("core", foundry.appv1.sheets.ActorSheet)
foundry.documents.collections.Actors.registerSheet("lethalFantasy", applications.LethalFantasyCharacterSheet, { types: ["character"], makeDefault: true }) foundry.documents.collections.Actors.registerSheet("lethalFantasy", applications.LethalFantasyCharacterSheet, { types: ["character"], makeDefault: true })
foundry.documents.collections.Actors.registerSheet("lethalFantasy", applications.LethalFantasyMonsterSheet, { types: ["monster"], makeDefault: true }) foundry.documents.collections.Actors.registerSheet("lethalFantasy", applications.LethalFantasyMonsterSheet, { types: ["monster"], makeDefault: true })
foundry.documents.collections.Items.unregisterSheet("core", foundry.appv1.sheets.ActorSheet)
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasySkillSheet, { types: ["skill"], makeDefault: true }) foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasySkillSheet, { types: ["skill"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyGiftSheet, { types: ["gift"], makeDefault: true }) foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyGiftSheet, { types: ["gift"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyVulnerabilitySheet, { types: ["vulnerability"], makeDefault: true }) foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyVulnerabilitySheet, { types: ["vulnerability"], makeDefault: true })
@@ -76,14 +82,6 @@ Hooks.once("init", function () {
// Dice system configuration // Dice system configuration
CONFIG.Dice.rolls.push(documents.LethalFantasyRoll) CONFIG.Dice.rolls.push(documents.LethalFantasyRoll)
game.settings.register("lethalFantasy", "worldKey", {
name: "Unique world key",
scope: "world",
config: false,
type: String,
default: "",
})
// Activate socket handler // Activate socket handler
game.socket.on(`system.${SYSTEM.id}`, LethalFantasyUtils.handleSocketEvent) game.socket.on(`system.${SYSTEM.id}`, LethalFantasyUtils.handleSocketEvent)
@@ -113,9 +111,8 @@ Hooks.once("ready", function () {
// Initialiser la table des résultats D30 // Initialiser la table des résultats D30
documents.D30Roll.initialize() documents.D30Roll.initialize()
if (!SYSTEM.DEV_MODE) { // Saignement piloté par le combat tracker
registerWorldCount("lethalFantasy") _registerBleedingHooks()
}
_showUserGuide() _showUserGuide()
@@ -129,6 +126,97 @@ Hooks.once("ready", function () {
} }
}) })
/**
* Saignement piloté par le combat tracker.
* Chaque round = 1 seconde → les acteurs qui saignent perdent 1 HP/blessure.
* Hors combat, une notification prévient le MJ que des blessures saignent encore.
*/
function _registerBleedingHooks() {
if (!game.user.isGM) return
Hooks.on("combatRound", async (combat, previous, current) => {
if (previous === current) return
const processed = new Set()
for (const combatant of combat.combatants) {
const actor = combatant.actor
if (!actor || processed.has(actor.id)) continue
processed.add(actor.id)
await _applyBleedingTick(actor)
}
})
Hooks.on("combatEnd", async (combat) => {
const bleeding = _findBleedingActors()
if (bleeding.length) {
ui.notifications.warn(
game.i18n.format("LETHALFANTASY.Notifications.bleedingCombatEnd", {
names: bleeding.map(a => a.name).join(", "),
})
)
}
})
Hooks.on("combatStart", async (combat) => {
const bleeding = _findBleedingActors()
if (bleeding.length) {
ui.notifications.warn(
game.i18n.format("LETHALFANTASY.Notifications.bleedingCombatStart", {
names: bleeding.map(a => a.name).join(", "),
})
)
}
})
}
/**
* Appliquer 1 HP de dégât par blessure active, décrémenter la durée.
* @param {import("foundry/common/documents.mjs").Actor} actor
*/
async function _applyBleedingTick(actor) {
if (!actor?.system?.hp?.wounds) return
const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
let hpLoss = 0
let changed = false
for (const wound of wounds) {
if (wound.duration > 0 && wound.value > 0) {
hpLoss += 1
wound.duration -= 1
if (wound.duration <= 0) {
wound.value = 0
wound.description = ""
}
changed = true
}
}
if (!changed) return
const currentHp = actor.system.hp.value ?? 0
await actor.update({
"system.hp.value": currentHp - hpLoss,
"system.hp.wounds": wounds,
})
}
/**
* Retourne les acteurs (monde + tokens) qui ont des blessures actives.
* @returns {import("foundry/common/documents.mjs").Actor[]}
*/
function _findBleedingActors() {
const actors = []
for (const actor of game.actors.values()) {
if (actor?.system?.hp?.wounds?.some(w => w.duration > 0 && w.value > 0)) {
actors.push(actor)
}
}
for (const token of canvas.tokens?.placeables ?? []) {
if (token.actor && !actors.includes(token.actor)) {
if (token.actor?.system?.hp?.wounds?.some(w => w.duration > 0 && w.value > 0)) {
actors.push(token.actor)
}
}
}
return actors
}
Hooks.on("renderChatMessageHTML", (message, html, data) => { Hooks.on("renderChatMessageHTML", (message, html, data) => {
const typeMessage = data.message.flags.lethalFantasy?.typeMessage const typeMessage = data.message.flags.lethalFantasy?.typeMessage
// Message de demande de jet de dés // Message de demande de jet de dés
@@ -239,9 +327,12 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
? (rollTargetData?._id || rollTargetData?.id) ? (rollTargetData?._id || rollTargetData?.id)
: (rollTargetData?.weapon?.id || rollTargetData?.weapon?._id) : (rollTargetData?.weapon?.id || rollTargetData?.weapon?._id)
const attackRollKey = rollTargetData?.rollKey const attackRollKey = rollTargetData?.rollKey
console.log(`[LF] request-defense-btn | attackRollType=${attackRollType} defender=${defenderName} defenderType=${combatant.actor?.type}`) log(`[LF] request-defense-btn | attackRollType=${attackRollType} defender=${defenderName} defenderType=${combatant.actor?.type}`)
const attackD30result = message.rolls[0]?.options?.D30result || null const attackD30result = message.rolls[0]?.options?.D30result || null
const attackD30message = message.rolls[0]?.options?.D30message || null const attackD30message = message.rolls[0]?.options?.D30message || null
const attackDiceResults = message.rolls[0]?.options?.diceResults || null
const attackNaturalRoll = attackDiceResults?.[0]?.value || null
const damageTier = message.rolls[0]?.options?.damageTier || "standard"
const attackRerollContext = { const attackRerollContext = {
rollType: message.rolls[0]?.options?.rollType, rollType: message.rolls[0]?.options?.rollType,
rollTarget: foundry.utils.duplicate(message.rolls[0]?.options?.rollTarget ?? {}), rollTarget: foundry.utils.duplicate(message.rolls[0]?.options?.rollTarget ?? {}),
@@ -277,6 +368,8 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
attackD30result, attackD30result,
attackD30message, attackD30message,
attackRerollContext, attackRerollContext,
attackNaturalRoll,
damageTier,
combatantId, combatantId,
tokenId, tokenId,
isRanged: isRangedAttack isRanged: isRangedAttack
@@ -338,6 +431,9 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
const damageFormula = btn.dataset.damageFormula const damageFormula = btn.dataset.damageFormula
const damageModifier = btn.dataset.damageModifier const damageModifier = btn.dataset.damageModifier
const isMonster = btn.dataset.isMonster const isMonster = btn.dataset.isMonster
const d30Bleed = btn.dataset.d30Bleed === "true"
const d30DamageMultiplier = Number(btn.dataset.d30DamageMult) || 1
const d30DrMultiplier = Number(btn.dataset.d30DrMult) || 1
// Récupérer l'acteur (soit depuis le message, soit depuis attackerId) // Récupérer l'acteur (soit depuis le message, soit depuis attackerId)
const actor = attackerId ? game.actors.get(attackerId) : game.actors.get(message.rolls[0]?.actorId) const actor = attackerId ? game.actors.get(attackerId) : game.actors.get(message.rolls[0]?.actorId)
@@ -375,12 +471,12 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
{ {
action: "cancel", action: "cancel",
label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo"), label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo"),
callback: () => null callback: () => "cancel"
} }
], ],
rejectClose: false rejectClose: false
}) })
if (manualDR === null) return if (manualDR === null || manualDR === "cancel") return
const rollOpts = { const rollOpts = {
type: "spell-damage", type: "spell-damage",
rollType: "spell-damage", rollType: "spell-damage",
@@ -392,32 +488,24 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
defenderTokenId, defenderTokenId,
actorId: actor.id, actorId: actor.id,
actorName: actor.name, actorName: actor.name,
actorImage: actor.img actorImage: actor.img,
d30Bleed,
d30DamageMultiplier,
d30DrMultiplier
} }
const roll = new documents.LethalFantasyRoll(damageFormula, {}, rollOpts) await documents.LethalFantasyRoll.rollSpellDamageToMessage(damageFormula, 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
if (game?.dice3d) await game.dice3d.showForRoll(roll, game.user, true)
await roll.toMessage()
return return
} }
// Pour les boutons de résultat de combat (monster damage) // Pour les boutons de résultat de combat (monster damage)
if (damageType === "monster" && attackKey) { if (damageType === "monster" && attackKey) {
await actor.system.prepareMonsterRoll("monster-damage", attackKey, undefined, undefined, undefined, defenderId, defenderTokenId, extraShieldDr) await actor.system.prepareMonsterRoll("monster-damage", attackKey, undefined, undefined, undefined, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
return return
} }
// Pour les monstres, utiliser prepareMonsterRoll // Pour les monstres, utiliser prepareMonsterRoll
if (isMonster === "true" || actor.type === "monster") { if (isMonster === "true" || actor.type === "monster") {
await actor.system.prepareMonsterRoll("monster-damage", weaponId, undefined, undefined, damageModifier) await actor.system.prepareMonsterRoll("monster-damage", weaponId, undefined, undefined, damageModifier, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
return return
} }
@@ -428,9 +516,9 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
return return
} }
// Lancer les dégâts avec la bonne méthode // Lancer les dégâts
const rollType = damageType === "small" ? "weapon-damage-small" : "weapon-damage-medium" const rollType = "weapon-damage"
await actor.prepareRoll(rollType, weaponId, undefined, defenderId, defenderTokenId, extraShieldDr) await actor.prepareRoll(rollType, weaponId, undefined, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
}) })
} }
@@ -458,7 +546,7 @@ Hooks.on("preCreateChatMessage", (message) => {
[`flags.${SYSTEM.id}.attackData`]: game.lethalFantasy.nextDefenseData [`flags.${SYSTEM.id}.attackData`]: game.lethalFantasy.nextDefenseData
}) })
console.log("Added attack data to defense message:", game.lethalFantasy.nextDefenseData) log("Added attack data to defense message:", game.lethalFantasy.nextDefenseData)
// Nettoyer // Nettoyer
delete game.lethalFantasy.nextDefenseData delete game.lethalFantasy.nextDefenseData
@@ -469,7 +557,7 @@ Hooks.on("preCreateChatMessage", (message) => {
Hooks.on("createChatMessage", async (message) => { Hooks.on("createChatMessage", async (message) => {
const rollType = message.rolls[0]?.options?.rollType const rollType = message.rolls[0]?.options?.rollType
console.log("Defense hook checking message, rollType:", rollType) log("Defense hook checking message, rollType:", rollType)
// Vérifier si c'est un message de défense // Vérifier si c'est un message de défense
if (rollType !== "weapon-defense" && rollType !== "monster-defense" && rollType !== "save") return if (rollType !== "weapon-defense" && rollType !== "monster-defense" && rollType !== "save") return
@@ -477,10 +565,10 @@ Hooks.on("createChatMessage", async (message) => {
// Récupérer les données d'attaque depuis les flags // Récupérer les données d'attaque depuis les flags
const attackData = message.flags?.[SYSTEM.id]?.attackData const attackData = message.flags?.[SYSTEM.id]?.attackData
console.log("Defense message confirmed, attackData:", attackData) log("Defense message confirmed, attackData:", attackData)
if (!attackData) { if (!attackData) {
console.log("No attack data found in message flags") log("No attack data found in message flags")
return return
} }
@@ -494,13 +582,15 @@ Hooks.on("createChatMessage", async (message) => {
attackRollKey, attackRollKey,
attackD30message, attackD30message,
attackRerollContext, attackRerollContext,
attackNaturalRoll,
damageTier,
defenderId, defenderId,
defenderTokenId defenderTokenId
} = attackData } = attackData
let defenseRoll = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0 let defenseRoll = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0
const defenseD30message = message.rolls[0]?.options?.D30message || null const defenseD30message = message.rolls[0]?.options?.D30message || null
console.log("Processing defense:", { attackRoll, defenseRoll, attackerId, defenderId }) log("Processing defense:", { attackRoll, defenseRoll, attackerId, defenderId })
// Attendre l'animation 3D // Attendre l'animation 3D
if (game?.dice3d) { if (game?.dice3d) {
@@ -537,19 +627,75 @@ Hooks.on("createChatMessage", async (message) => {
}) })
} }
// Si le défenseur est un personnage qui perd, proposer Grit/Luck (seulement s'il a des points) // Detect cross-client scenario: attacker has an active non-GM owner on another client
// Seulement si l'utilisateur actuel est le propriétaire du défenseur const attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER"))
const attackerIsCrossClient = attackerHasNonGMOwner && !isPrimaryController(attacker)
// Reaction phase — both sides may use grit/luck/shield/mulligan before the outcome is resolved.
// After a mulligan reroll (either side), the comparison restarts so both sides can react to the new numbers.
let defenderHandledBonus = false let defenderHandledBonus = false
let attackerHandledBonus = false
let shieldReaction = null let shieldReaction = null
let shieldBlocked = false let shieldBlocked = false
const isSpellOrMiracle = attackRollType === "spell-attack" || attackRollType === "miracle-attack" const isSpellOrMiracle = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
if (defender && defenseRoll < attackRoll && isPrimaryController(defender) && !isSpellOrMiracle) {
// These persist across mulligan restarts (once used they stay consumed)
const shieldData = LethalFantasyUtils.getShieldReactionData(defender) const shieldData = LethalFantasyUtils.getShieldReactionData(defender)
let canRerollDefense = LethalFantasyUtils.hasD30Reroll(defenseD30message) let canRerollDefense = LethalFantasyUtils.hasD30Reroll(defenseD30message)
let canShieldReact = !!shieldData let canShieldReact = !!shieldData
let canAdHocShield = !shieldData let canAdHocShield = !shieldData
let attackRollFinal = attackRoll
let canRerollAttack = LethalFantasyUtils.hasD30Reroll(attackD30message)
let mulliganRestart = false
// These persist across mulligan restarts (D30 bonus only applied once)
let defenseD30Processed = false
let attackD30Processed = false
// D30 combat effects for damage application
let d30Bleed = false
let d30DamageMultiplier = 1
let d30DrMultiplier = 1
while (defenseRoll < attackRoll) { do {
mulliganRestart = false
defenderHandledBonus = false
attackerHandledBonus = false
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
if (defenseD30message && !defenseD30Processed && isPrimaryController(defender) && !attackerIsCrossClient) {
const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true)
if (d30Result.modifier) {
defenseRoll += d30Result.modifier
if (d30Result.modifier > 0) {
await createReactionMessage(defender,
`<p><strong>${defenderName}</strong> gains <strong>+${d30Result.modifier}</strong> from D30 bonus die for defense.</p>`
)
}
}
if (d30Result.specialEffect === "auto") {
defenseRoll = attackRollFinal + 1 // auto-block
await createReactionMessage(defender,
`<p><strong>${defenderName}</strong> uses <strong>${d30Result.specialName || "Special Defense"}</strong> from D30 — defense automatically succeeds!</p>`
)
}
if (d30Result.specialEffect === "flag") {
await createReactionMessage(defender,
`<p>D30 — <strong>${d30Result.specialName || "Special Effect"}</strong> triggered for ${defenderName}!</p>`
)
}
if (d30Result.specialEffect === "drMultiplier") {
d30DrMultiplier = d30Result.multiplier
await createReactionMessage(defender,
`<p>D30 — Defense grants <strong>x${d30Result.multiplier} DR</strong> (choose which DR types to multiply when damage is applied)</p>`
)
}
defenseD30Processed = true
}
// ── Defense reaction loop ──────────────────────────────────────────────
// Skip when attacker is cross-client — the socket handler (handleAttackBoosted)
// will show the defense dialog and create the comparison message.
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient) {
while (defenseRoll < attackRollFinal) {
const currentGrit = Number(defender.system?.grit?.current) || 0 const currentGrit = Number(defender.system?.grit?.current) || 0
const currentLuck = Number(defender.system?.luck?.current) || 0 const currentLuck = Number(defender.system?.luck?.current) || 0
const buttons = [] const buttons = []
@@ -582,7 +728,7 @@ Hooks.on("createChatMessage", async (message) => {
if (canRerollDefense) { if (canRerollDefense) {
buttons.push({ buttons.push({
action: "rerollDefense", action: "rerollDefense",
label: "Re-roll defense", label: "Re-roll defense (Mulligan)",
icon: "fa-solid fa-rotate-right", icon: "fa-solid fa-rotate-right",
callback: () => "rerollDefense" callback: () => "rerollDefense"
}) })
@@ -596,7 +742,6 @@ Hooks.on("createChatMessage", async (message) => {
callback: () => "shieldReact" callback: () => "shieldReact"
}) })
} else if (canAdHocShield) { } else if (canAdHocShield) {
// No pre-configured shield — offer ad-hoc shield option (useful for monsters)
buttons.push({ buttons.push({
action: "adHocShield", action: "adHocShield",
label: "Roll ad-hoc shield (choose dice + DR)", label: "Roll ad-hoc shield (choose dice + DR)",
@@ -618,7 +763,7 @@ Hooks.on("createChatMessage", async (message) => {
content: ` content: `
<div class="grit-luck-dialog"> <div class="grit-luck-dialog">
<div class="combat-status"> <div class="combat-status">
<p><strong>${attackerName}</strong> rolled <strong>${attackRoll}</strong></p> <p><strong>${attackerName}</strong> rolled <strong>${attackRollFinal}</strong></p>
<p><strong>${defenderName}</strong> currently has <strong>${defenseRoll}</strong></p> <p><strong>${defenderName}</strong> currently has <strong>${defenseRoll}</strong></p>
${defenseD30message ? `<p class="bonus-info">D30 special: ${defenseD30message.description}</p>` : ""} ${defenseD30message ? `<p class="bonus-info">D30 special: ${defenseD30message.description}</p>` : ""}
</div> </div>
@@ -634,21 +779,21 @@ Hooks.on("createChatMessage", async (message) => {
defenderHandledBonus = true defenderHandledBonus = true
if (choice === "grit") { 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>`) const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, total => `<p><strong>${defenderName}</strong> spends 1 Grit and rolls <strong>${total}</strong> for defense.</p>`)
defenseRoll += bonusRoll defenseRoll += bonusRoll
await defender.update({ "system.grit.current": currentGrit - 1 }) await defender.update({ "system.grit.current": currentGrit - 1 })
continue continue
} }
if (choice === "luck") { 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>`) const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, total => `<p><strong>${defenderName}</strong> spends 1 Luck and rolls <strong>${total}</strong> for defense.</p>`)
defenseRoll += bonusRoll defenseRoll += bonusRoll
await defender.update({ "system.luck.current": currentLuck - 1 }) await defender.update({ "system.luck.current": currentLuck - 1 })
continue continue
} }
if (choice === "bonusDie") { if (choice === "bonusDie") {
const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", defenseRoll, attackRoll) const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", defenseRoll, attackRollFinal)
if (!bonusDie) continue if (!bonusDie) continue
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>`) 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>`)
defenseRoll += bonusRoll defenseRoll += bonusRoll
@@ -661,8 +806,10 @@ Hooks.on("createChatMessage", async (message) => {
canRerollDefense = false canRerollDefense = false
if (!reroll) continue if (!reroll) continue
defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll
await createReactionMessage(defender, `<p><strong>${defenderName}</strong> uses Mulligan and re-rolls defense: <strong>${oldDefenseRoll}</strong> → <strong>${defenseRoll}</strong>.</p>`) await createReactionMessage(defender, `<p><strong>${defenderName}</strong> uses Mulligan and re-rolls defense: <strong>${oldDefenseRoll}</strong> → <strong>${defenseRoll}</strong>. Both sides may now react to the new numbers.</p>`)
continue // Restart the full comparison so both sides can react to the new roll
mulliganRestart = true
break
} }
if (choice === "shieldReact" && canShieldReact) { if (choice === "shieldReact" && canShieldReact) {
@@ -671,7 +818,7 @@ Hooks.on("createChatMessage", async (message) => {
defenseRoll = newDefenseTotal defenseRoll = newDefenseTotal
canShieldReact = false canShieldReact = false
if (newDefenseTotal >= attackRoll) { if (newDefenseTotal >= attackRollFinal) {
shieldBlocked = true shieldBlocked = true
shieldReaction = { shieldReaction = {
damageReduction: shieldData.damageReduction, damageReduction: shieldData.damageReduction,
@@ -680,19 +827,19 @@ Hooks.on("createChatMessage", async (message) => {
} }
await createReactionMessage( await createReactionMessage(
defender, defender,
`<p><strong>${defenderName}</strong> rolls <strong>${shieldData.label}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal}${attackRoll}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${shieldData.damageReduction}</strong> will apply to damage.</p>` `<p><strong>${defenderName}</strong> rolls <strong>${shieldData.label}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal}${attackRollFinal}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${shieldData.damageReduction}</strong> will apply to damage.</p>`
) )
} else { } else {
shieldReaction = null shieldReaction = null
await createReactionMessage( await createReactionMessage(
defender, defender,
`<p><strong>${defenderName}</strong> rolls <strong>${shieldData.label}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRoll}). Shield did not block — normal hit, armor DR only.</p>` `<p><strong>${defenderName}</strong> rolls <strong>${shieldData.label}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.</p>`
) )
} }
} }
if (choice === "adHocShield") { if (choice === "adHocShield") {
const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRoll, defenseRoll) const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, defenseRoll)
if (!adHoc) continue if (!adHoc) continue
const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender) const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender)
const newDefenseTotal = defenseRoll + shieldBonus const newDefenseTotal = defenseRoll + shieldBonus
@@ -700,7 +847,7 @@ Hooks.on("createChatMessage", async (message) => {
canShieldReact = false canShieldReact = false
canAdHocShield = false canAdHocShield = false
if (newDefenseTotal >= attackRoll) { if (newDefenseTotal >= attackRollFinal) {
shieldBlocked = true shieldBlocked = true
shieldReaction = { shieldReaction = {
damageReduction: adHoc.damageReduction, damageReduction: adHoc.damageReduction,
@@ -709,27 +856,75 @@ Hooks.on("createChatMessage", async (message) => {
} }
await createReactionMessage( await createReactionMessage(
defender, defender,
`<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal}${attackRoll}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${adHoc.damageReduction}</strong> will apply to damage.</p>` `<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>`
) )
} else { } else {
shieldReaction = null shieldReaction = null
await createReactionMessage( await createReactionMessage(
defender, defender,
`<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRoll}). Shield did not block — normal hit, armor DR only.</p>` `<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>`
) )
} }
} }
} }
} }
let attackRollFinal = attackRoll if (mulliganRestart) continue
let attackerHandledBonus = false
// Si l'attaquant est un personnage qui perd et a du Grit // ── D30 bonus dice (attack) — resolved before grit/luck ────────────────
// Seulement si l'utilisateur actuel est le propriétaire de l'attaquant (pas le MJ) if (attackD30message && !attackD30Processed) {
if (!defenderHandledBonus && attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) { const preD30AttackRoll = attackRollFinal
let canRerollAttack = LethalFantasyUtils.hasD30Reroll(attackD30message) const canDialog = isPrimaryController(attacker)
const d30Result = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, canDialog)
if (d30Result.modifier) {
attackRollFinal += d30Result.modifier
if (d30Result.modifier > 0 && canDialog) {
await createReactionMessage(attacker,
`<p><strong>${attackerName}</strong> gains <strong>+${d30Result.modifier}</strong> from D30 bonus die for attack.</p>`
)
}
}
if (d30Result.specialEffect === "auto") {
attackRollFinal = defenseRoll + 1 // auto-hit
if (canDialog) {
await createReactionMessage(attacker,
`<p><strong>${attackerName}</strong> uses <strong>${d30Result.specialName || "Special Strike"}</strong> from D30 — attack automatically hits!</p>`
)
}
}
if (d30Result.specialEffect === "flag" && canDialog) {
await createReactionMessage(attacker,
`<p>D30 — <strong>${d30Result.specialName || "Special Effect"}</strong> triggered for ${attackerName}!</p>`
)
}
if (d30Result.specialEffect === "bleed") {
d30Bleed = true
if (canDialog) {
await createReactionMessage(attacker,
`<p>D30 — <strong>Bleeding/Internal Injury</strong> on hit! Damage past DR will cause a bleeding wound.</p>`
)
}
}
if (d30Result.specialEffect === "damageMultiplier") {
d30DamageMultiplier = d30Result.multiplier
if (canDialog) {
await createReactionMessage(attacker,
`<p>D30 — <strong>x${d30Result.multiplier} damage</strong> before damage reduction!</p>`
)
}
}
attackD30Processed = true
// If D30 boosted attack past defense, restart so defender can react.
// Only restart when D30 actually changed the outcome (pre-D30 defender was
// winning or tied, post-D30 defender is losing).
if (defender && preD30AttackRoll <= defenseRoll && defenseRoll < attackRollFinal) {
mulliganRestart = true
continue
}
}
// ── Attack reaction loop ───────────────────────────────────────────────
if (attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) {
while (attackRollFinal <= defenseRoll) { while (attackRollFinal <= defenseRoll) {
const currentGrit = Number(attacker.system?.grit?.current) || 0 const currentGrit = Number(attacker.system?.grit?.current) || 0
const buttons = [] const buttons = []
@@ -753,7 +948,7 @@ Hooks.on("createChatMessage", async (message) => {
if (canRerollAttack && attackRerollContext) { if (canRerollAttack && attackRerollContext) {
buttons.push({ buttons.push({
action: "rerollAttack", action: "rerollAttack",
label: "Re-roll attack", label: "Re-roll attack (Mulligan)",
icon: "fa-solid fa-rotate-right", icon: "fa-solid fa-rotate-right",
callback: () => "rerollAttack" callback: () => "rerollAttack"
}) })
@@ -788,7 +983,7 @@ Hooks.on("createChatMessage", async (message) => {
attackerHandledBonus = true attackerHandledBonus = true
if (choice === "grit") { if (choice === "grit") {
const attackBonus = await LethalFantasyUtils.rollBonusDie("1d6", attacker, (total) => `<p><strong>${attackerName}</strong> spends 1 Grit and rolls <strong>${total}</strong> for attack.</p>`) const attackBonus = await LethalFantasyUtils.rollBonusDie("1d6", attacker, total => `<p><strong>${attackerName}</strong> spends 1 Grit and rolls <strong>${total}</strong> for attack.</p>`)
attackRollFinal += attackBonus attackRollFinal += attackBonus
await attacker.update({ "system.grit.current": currentGrit - 1 }) await attacker.update({ "system.grit.current": currentGrit - 1 })
continue continue
@@ -808,20 +1003,74 @@ Hooks.on("createChatMessage", async (message) => {
canRerollAttack = false canRerollAttack = false
if (!reroll) continue if (!reroll) continue
attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll
await createReactionMessage(attacker, `<p><strong>${attackerName}</strong> uses Mulligan and re-rolls attack: <strong>${oldAttackRoll}</strong> → <strong>${attackRollFinal}</strong>.</p>`) await createReactionMessage(attacker, `<p><strong>${attackerName}</strong> uses Mulligan and re-rolls attack: <strong>${oldAttackRoll}</strong> → <strong>${attackRollFinal}</strong>. Both sides may now react to the new numbers.</p>`)
// Restart the full comparison so both sides can react to the new roll
mulliganRestart = true
break
} }
} }
} }
const shieldDamageReduction = shieldBlocked ? shieldReaction.damageReduction : 0 // Cross-client coordination: only delegate to the defender's client
// when the attacker boosted past the defense. When no attacker boost
// occurred, the defender's client already processed the defense via
// the createChatMessage hook and will create the correct comparison.
// Sending attackBoosted with stale (unboosted) values would cause
// the defender to see a duplicate dialog and overwrite the result.
if (defender && isPrimaryController(attacker)) {
const defenderOwner = game.users.find(u => u.active && !u.isGM && defender.testUserPermission(u, "OWNER"))
|| game.users.find(u => u.active && u.isGM)
if (defenderOwner && defenderOwner.id !== game.user.id) {
// Send attackBoosted when the attacker actually boosted (so defender
// can respond to the new numbers), OR when the attacker has an active
// non-GM owner (PC-vs-PC cross-client) — the defender's hook-based
// processing is suppressed by attackerIsCrossClient, so the socket
// handler must show the defense dialog instead.
if (attackerHandledBonus || attackerHasNonGMOwner) {
const sData = LethalFantasyUtils.getShieldReactionData(defender)
game.socket.emit(`system.${SYSTEM.id}`, {
type: "attackBoosted",
userId: defenderOwner.id,
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
shieldDamageReduction: shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0,
d30Bleed: d30Bleed ? "true" : "",
d30DamageMultiplier, d30DrMultiplier,
damageTier: damageTier || "standard",
attackD30message,
defenseD30message,
hasShield: !!sData,
shieldLabel: sData?.label || "",
shieldFormula: sData?.formula || "",
shieldDr: sData?.damageReduction || 0,
canAdHocShield: !sData,
})
}
return
}
// Same client: restart for defender loop if attacker boosted past defense
if (defenseRoll < attackRollFinal && attackerHandledBonus) {
mulliganRestart = true
}
}
} while (mulliganRestart)
const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0
const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss") const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss")
// Créer le message de comparaison - uniquement par le client qui a géré le dernier bonus // Only one client should create the comparison message:
// Priorité: attaquant si il a géré le bonus, sinon défenseur si il a géré le bonus, sinon défenseur // 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
const shouldCreateMessage = attackerHandledBonus || (!attackerHandledBonus && defenderHandledBonus) || (!attackerHandledBonus && !defenderHandledBonus && isPrimaryController(defender)) // 2. Defender boosted → defender's client creates
// 3. Nobody boosted → attacker's client (preferred) or defender's client (fallback if same-client)
const shouldCreateMessage = attackerHandledBonus
|| (!attackerHandledBonus && defenderHandledBonus)
|| (!attackerHandledBonus && !defenderHandledBonus && (
(isPrimaryController(defender) && !attackerIsCrossClient)
|| isPrimaryController(attacker)
))
if (shouldCreateMessage) { if (shouldCreateMessage) {
console.log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner }) log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
await LethalFantasyUtils.compareAttackDefense({ await LethalFantasyUtils.compareAttackDefense({
attackerName, attackerName,
@@ -835,10 +1084,15 @@ Hooks.on("createChatMessage", async (message) => {
defenderTokenId, defenderTokenId,
defenseRoll, defenseRoll,
outcome, outcome,
shieldDamageReduction shieldDamageReduction,
d30Bleed: d30Bleed ? "true" : "",
d30DamageMultiplier: d30DamageMultiplier,
d30DrMultiplier: d30DrMultiplier,
damageTier: damageTier || "standard",
attackD30message
}) })
} else { } else {
console.log("Skipping message creation", { attackerHandledBonus, defenderHandledBonus }) log("Skipping message creation", { attackerHandledBonus, defenderHandledBonus })
} }
}) })
@@ -863,15 +1117,19 @@ Hooks.on("createChatMessage", async (message) => {
const spell = spellId ? actor.items.get(spellId) : null const spell = spellId ? actor.items.get(spellId) : null
if (!spell || spell.type !== "spell") return if (!spell || spell.type !== "spell") return
const cost = Number(spell.system?.cost) || 0 const damageTier = message.rolls[0]?.options?.damageTier || "standard"
const tierCostMap = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" }
const costField = tierCostMap[damageTier] || "cost"
const cost = Number(spell.system?.[costField]) || 0
if (cost <= 0) return if (cost <= 0) return
const currentAether = Number(actor.system.aetherPoints?.value) || 0 const currentAether = Number(actor.system.aetherPoints?.value) || 0
const newAether = Math.max(0, currentAether - cost) const newAether = Math.max(0, currentAether - cost)
await actor.update({ "system.aetherPoints.value": newAether }) await actor.update({ "system.aetherPoints.value": newAether })
const tierLabel = damageTier === "standard" ? "" : ` (${damageTier})`
await ChatMessage.create({ await ChatMessage.create({
content: `<p>🔮 <strong>${actor.name}</strong> casts <em>${spell.name}</em> — spends <strong>${cost}</strong> Aether <span style="color:#888;">(${currentAether}${newAether})</span>.</p>`, content: `<p>🔮 <strong>${actor.name}</strong> casts <em>${spell.name}${tierLabel}</em> — spends <strong>${cost}</strong> Aether <span style="color:#888;">(${currentAether}${newAether})</span>.</p>`,
speaker: ChatMessage.getSpeaker({ actor }) speaker: ChatMessage.getSpeaker({ actor })
}) })
}) })
@@ -915,7 +1173,7 @@ Hooks.on("createChatMessage", async (message) => {
const defenderId = message.rolls[0]?.options?.defenderId const defenderId = message.rolls[0]?.options?.defenderId
const isDamage = message.rolls[0]?.options?.rollData?.isDamage const isDamage = message.rolls[0]?.options?.rollData?.isDamage
console.log("Auto-damage hook:", { defenderId, isDamage, rollType: message.rolls[0]?.options?.rollType }) log("Auto-damage hook:", { defenderId, isDamage, rollType: message.rolls[0]?.options?.rollType })
if (!defenderId || !isDamage) return if (!defenderId || !isDamage) return
@@ -940,11 +1198,11 @@ Hooks.on("createChatMessage", async (message) => {
} }
if (!shouldApplyDamage) { if (!shouldApplyDamage) {
console.log("Auto-damage hook: Not responsible for applying damage, skipping") log("Auto-damage hook: Not responsible for applying damage, skipping")
return return
} }
console.log("Auto-damage hook: Applying damage as responsible user") log("Auto-damage hook: Applying damage as responsible user")
// Attendre l'animation 3D avant d'appliquer les dégâts // Attendre l'animation 3D avant d'appliquer les dégâts
if (game?.dice3d) { if (game?.dice3d) {
@@ -964,13 +1222,94 @@ Hooks.on("createChatMessage", async (message) => {
const attackerName = message.rolls[0]?.options?.actorName || "Unknown Attacker" const attackerName = message.rolls[0]?.options?.actorName || "Unknown Attacker"
const rollType = message.rolls[0]?.options?.rollType const rollType = message.rolls[0]?.options?.rollType
// Lire les effets D30
const d30Bleed = message.rolls[0]?.options?.d30Bleed || false
const d30DamageMultiplier = message.rolls[0]?.options?.d30DamageMultiplier || 1
const d30DrMultiplier = message.rolls[0]?.options?.d30DrMultiplier || 1
// Appliquer le multiplicateur de dégâts D30 au total AVANT DR
const rawDamage = damageTotal * d30DamageMultiplier
// Calculer les DR — les sorts utilisent une DR manuelle saisie par l'utilisateur // Calculer les DR — les sorts utilisent une DR manuelle saisie par l'utilisateur
const isSpellDamage = rollType === "spell-damage" const isSpellDamage = rollType === "spell-damage"
const manualDR = message.rolls[0]?.options?.manualDR ?? 0 const manualDR = message.rolls[0]?.options?.manualDR ?? 0
const extraShieldDr = Number(message.rolls[0]?.options?.extraShieldDr) || 0 const extraShieldDr = Number(message.rolls[0]?.options?.extraShieldDr) || 0
const armorDR = isSpellDamage ? manualDR : (defender.computeDamageReduction() || 0)
const totalDR = isSpellDamage ? manualDR : armorDR + extraShieldDr // Décomposer les DR en composants
const finalDamage = Math.max(0, damageTotal - totalDR) let baseDR = 0
let shieldDR = 0
let magicDR = 0
if (isSpellDamage) {
baseDR = manualDR
} else {
const totalDefDR = defender.computeDamageReduction() || 0
magicDR = defender.getMagicDR() || 0
baseDR = totalDefDR - magicDR // naturalDR + armorDR (ou hpDR + combatDR pour les monstres)
shieldDR = extraShieldDr
}
// Appliquer le multiplicateur de DR D30 si actif — boîte de dialogue
let appliedBaseDR = baseDR
let appliedShieldDR = shieldDR
let appliedMagicDR = magicDR
if (d30DrMultiplier > 1) {
const drResult = await (async () => {
const checks = {
base: true,
shield: shieldDR > 0,
magic: magicDR > 0
}
const html = `
<div class="grit-luck-dialog">
<p><strong>D30 DR Multiplier ×${d30DrMultiplier}</strong></p>
<p>Choose which DR types to multiply:</p>
<label style="display:block;margin:0.3rem 0">
<input type="checkbox" id="d30-dr-base" ${checks.base ? "checked" : ""} ${baseDR <= 0 ? "disabled" : ""}>
Base DR (Armor/Natural): ${baseDR}×${d30DrMultiplier} = ${baseDR * d30DrMultiplier}
</label>
<label style="display:block;margin:0.3rem 0">
<input type="checkbox" id="d30-dr-shield" ${checks.shield ? "checked" : ""} ${shieldDR <= 0 ? "disabled" : ""}>
Shield DR: ${shieldDR}×${d30DrMultiplier} = ${shieldDR * d30DrMultiplier}
</label>
<label style="display:block;margin:0.3rem 0">
<input type="checkbox" id="d30-dr-magic" ${checks.magic ? "checked" : ""} ${magicDR <= 0 ? "disabled" : ""}>
Magic DR: ${magicDR}×${d30DrMultiplier} = ${magicDR * d30DrMultiplier}
</label>
</div>
`
const result = await foundry.applications.api.DialogV2.wait({
window: { title: "Apply D30 DR Multiplier" },
classes: ["lethalfantasy"],
content: html,
buttons: [
{
action: "apply",
label: "Apply Damage",
icon: "fa-solid fa-check",
callback: (event, button) => {
const form = button.form || button.closest("form")
return {
applyBase: form.querySelector("#d30-dr-base")?.checked || false,
applyShield: form.querySelector("#d30-dr-shield")?.checked || false,
applyMagic: form.querySelector("#d30-dr-magic")?.checked || false
}
}
}
],
rejectClose: false
})
return result || { applyBase: false, applyShield: false, applyMagic: false }
})()
appliedBaseDR = drResult.applyBase ? baseDR * d30DrMultiplier : baseDR
appliedShieldDR = drResult.applyShield ? shieldDR * d30DrMultiplier : shieldDR
appliedMagicDR = drResult.applyMagic ? magicDR * d30DrMultiplier : magicDR
}
const totalDR = appliedBaseDR + appliedShieldDR + appliedMagicDR
const finalDamage = Math.max(0, rawDamage - totalDR)
// Prefer the token ID stored in roll options (set at attack time when the exact token is known). // Prefer the token ID stored in roll options (set at attack time when the exact token is known).
// For unlinked tokens (default for monsters), this ensures we target the right instance even // For unlinked tokens (default for monsters), this ensures we target the right instance even
@@ -985,16 +1324,48 @@ Hooks.on("createChatMessage", async (message) => {
// Apply damage. If the current user does not own the defender (e.g. player hitting a GM monster), // Apply damage. If the current user does not own the defender (e.g. player hitting a GM monster),
// route the HP update to the GM via socket. The confirmation message is still created here // route the HP update to the GM via socket. The confirmation message is still created here
// since all users can create chat messages. // since all users can create chat messages.
const applyDamageToActor = async (actor) => {
await actor.applyDamage(-finalDamage)
// Create bleeding wound if D30 triggered it
if (d30Bleed && finalDamage > 0 && actor.system.hp?.wounds) {
const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
const slot = wounds.findIndex(w => !w.value && !w.duration)
if (slot !== -1) {
wounds[slot] = { value: finalDamage, duration: finalDamage, description: "Bleeding" }
await actor.update({ "system.hp.wounds": wounds })
}
}
}
if (defender.isOwner) { if (defender.isOwner) {
// Resolve the token actor: prefer lookup by token ID (exact match for unlinked monsters),
// fall back to combatant actor, then base world actor.
const tokenActor = (defenderTokenId const tokenActor = (defenderTokenId
? canvas.tokens?.placeables?.find(t => t.id === defenderTokenId)?.actor ? canvas.tokens?.placeables?.find(t => t.id === defenderTokenId)?.actor
: defenderCombatant?.actor) ?? defender : defenderCombatant?.actor) ?? defender
await tokenActor.applyDamage(-finalDamage) await applyDamageToActor(tokenActor)
} else { } else {
game.socket.emit(`system.${SYSTEM.id}`, { type: "applyDamage", actorId: defender.id, tokenId: defenderTokenId, damage: -finalDamage }) game.socket.emit(`system.${SYSTEM.id}`, { type: "applyDamage", actorId: defender.id, tokenId: defenderTokenId, damage: -finalDamage })
// Also emit wound creation for bleeding
if (d30Bleed && finalDamage > 0 && defender.system.hp?.wounds) {
game.socket.emit(`system.${SYSTEM.id}`, { type: "applyBleeding", actorId: defender.id, tokenId: defenderTokenId, damage: finalDamage })
} }
}
// Build DR text for confirmation message
let drText = ""
if (isSpellDamage) {
drText = manualDR > 0 ? `Spell DR: ${manualDR}` : "No DR applied"
} else {
const parts = []
if (appliedBaseDR > 0) parts.push(`Base DR: ${appliedBaseDR}${d30DrMultiplier > 1 && appliedBaseDR !== baseDR ? ` (×${d30DrMultiplier})` : ""}`)
if (appliedShieldDR > 0) parts.push(`Shield DR: ${appliedShieldDR}${d30DrMultiplier > 1 && appliedShieldDR !== shieldDR ? ` (×${d30DrMultiplier})` : ""}`)
if (appliedMagicDR > 0) parts.push(`Magic DR: ${appliedMagicDR}${d30DrMultiplier > 1 && appliedMagicDR !== magicDR ? ` (×${d30DrMultiplier})` : ""}`)
drText = parts.length > 0 ? parts.join(" + ") : "No DR applied"
}
// Build raw damage text showing D30 multiplier if active
const rawDamageText = d30DamageMultiplier > 1
? `${damageTotal} × ${d30DamageMultiplier} = ${rawDamage}`
: String(damageTotal)
// Créer un message de confirmation (visible to GM only) // Créer un message de confirmation (visible to GM only)
const messageContent = await foundry.applications.handlebars.renderTemplate( const messageContent = await foundry.applications.handlebars.renderTemplate(
@@ -1002,17 +1373,21 @@ Hooks.on("createChatMessage", async (message) => {
{ {
targetName: defender.name, targetName: defender.name,
damage: finalDamage, damage: finalDamage,
drText: isSpellDamage drText,
? (manualDR > 0 ? `Spell DR: ${manualDR}` : "No DR applied")
: (totalDR > 0 ? `Armor DR: ${armorDR}${extraShieldDr > 0 ? ` + Shield DR: ${extraShieldDr}` : ""}` : ""),
weaponName: weaponName, weaponName: weaponName,
attackerName: attackerName, attackerName: attackerName,
rawDamage: damageTotal rawDamage: rawDamageText
} }
) )
// Add bleeding notification
let bleedContent = ""
if (d30Bleed && finalDamage > 0) {
bleedContent = `<p><strong>Bleeding:</strong> Wound of ${finalDamage} HP for ${finalDamage} seconds.</p>`
}
await ChatMessage.create({ await ChatMessage.create({
content: messageContent, content: messageContent + bleedContent,
speaker: ChatMessage.getSpeaker({ actor: defender }), speaker: ChatMessage.getSpeaker({ actor: defender }),
whisper: ChatMessage.getWhisperRecipients("GM") whisper: ChatMessage.getWhisperRecipients("GM")
}) })
@@ -1038,16 +1413,4 @@ Hooks.on("hotbarDrop", (bar, data, slot) => {
*/ */
Hooks.on("renderChatLog", (_chatLog, html) => applications.injectDiceTray(_chatLog, html)) Hooks.on("renderChatLog", (_chatLog, html) => applications.injectDiceTray(_chatLog, html))
/**
* Register world usage statistics
* @param {string} registerKey
*/
async function registerWorldCount(registerKey) {
if (game.user.isGM) {
try {
ClassCounter.registerUsageCount(game.system.id, {})
} catch {
console.log("No usage log ")
}
}
}
+13 -41
View File
@@ -24,30 +24,29 @@ export class LethalFantasyCombatTracker extends foundry.applications.sidebar.tab
async _prepareContext(options) { async _prepareContext(options) {
let data = await super._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) { /*for (let u of data.turns) {
let c = game.combat.combatants.get(u.id); let c = game.combat.combatants.get(u.id);
u.progressionCount = c.system.progressionCount u.progressionCount = c.system.progressionCount
u.isMonster = c.actor.type === "monster" u.isMonster = c.actor.type === "monster"
} }
console.log("Combat Data", data);*/ log("Combat Data", data);*/
return data; return data;
} }
static #initiativePlus(ev) { static async #initiativePlus(ev) {
ev.preventDefault(); ev.preventDefault();
let cId = ev.target.closest(".combatant").dataset.combatantId; let cId = ev.target.closest(".combatant").dataset.combatantId;
let c = game.combat.combatants.get(cId); let c = game.combat.combatants.get(cId);
c.update({ 'initiative': c.initiative + 1 }); await c.update({ 'initiative': c.initiative + 1 });
console.log("Initiative Plus");
} }
static #initiativeMinus(ev) { static async #initiativeMinus(ev) {
ev.preventDefault(); ev.preventDefault();
let cId = ev.target.closest(".combatant").dataset.combatantId; let cId = ev.target.closest(".combatant").dataset.combatantId;
let c = game.combat.combatants.get(cId); let c = game.combat.combatants.get(cId);
let newInit = Math.max(c.initiative - 1, 0); 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(); ev.preventDefault();
await game.combat.rollMonsterProgression(); 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 { export class LethalFantasyCombat extends Combat {
@@ -94,7 +66,7 @@ export class LethalFantasyCombat extends Combat {
* @returns {Combatant[]} * @returns {Combatant[]}
*/ */
setupTurns() { setupTurns() {
console?.log("Setup Turns...."); log("Setup Turns....");
this.turns ||= []; this.turns ||= [];
// Determine the turn order and the current turn // 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); 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); 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); let c = this.combatants.get(cId);
c.setFlag(SYSTEM.id, "casting", false); await c.setFlag(SYSTEM.id, "casting", false);
} }
isCasting(cId) { isCasting(cId) {
@@ -78,7 +78,6 @@ export default class LethalFantasyActorSheet extends HandlebarsApplicationMixin(
/** @override */ /** @override */
_onRender(context, options) { _onRender(context, options) {
this.#dragDrop.forEach((d) => d.bind(this.element)) this.#dragDrop.forEach((d) => d.bind(this.element))
// Add listeners to rollable elements
const rollables = this.element.querySelectorAll(".rollable") const rollables = this.element.querySelectorAll(".rollable")
rollables.forEach((d) => d.addEventListener("click", this._onRoll.bind(this))) 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 attr = target.dataset.edit
const current = foundry.utils.getProperty(this.document, attr) const current = foundry.utils.getProperty(this.document, attr)
const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {} const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {}
const fp = new FilePicker({ const fp = new foundry.applications.ux.FilePicker.implementation({
current, current,
type: "image", type: "image",
redirectToRoot: img ? [img] : [], redirectToRoot: img ? [img] : [],
callback: (path) => { callback: async (path) => {
this.document.update({ [attr]: path }) await this.document.update({ [attr]: path })
}, },
top: this.position.top + 40, top: this.position.top + 40,
left: this.position.left + 10, left: this.position.left + 10,
@@ -261,7 +260,7 @@ export default class LethalFantasyActorSheet extends HandlebarsApplicationMixin(
item = await fromUuid(uuid) item = await fromUuid(uuid)
if (!item) item = this.document.items.get(id) if (!item) item = this.document.items.get(id)
if (!item) return if (!item) return
item.sheet.render(true) item.sheet.render({ force: true })
} }
/** /**
@@ -284,8 +283,8 @@ export default class LethalFantasyActorSheet extends HandlebarsApplicationMixin(
* @private * @private
* @static * @static
*/ */
static #onCreateSpell(event, target) { static async #onCreateSpell(event, target) {
const item = this.document.createEmbeddedDocuments("Item", [{ name: "Nouveau sortilège", type: "spell" }]) await this.document.createEmbeddedDocuments("Item", [{ name: "Nouveau sortilège", type: "spell" }])
} }
// #endregion // #endregion
@@ -177,12 +177,12 @@ export default class LethalFantasyItemSheet extends HandlebarsApplicationMixin(f
const attr = target.dataset.edit const attr = target.dataset.edit
const current = foundry.utils.getProperty(this.document, attr) const current = foundry.utils.getProperty(this.document, attr)
const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {} const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {}
const fp = new FilePicker({ const fp = new foundry.applications.ux.FilePicker.implementation({
current, current,
type: "image", type: "image",
redirectToRoot: img ? [img] : [], redirectToRoot: img ? [img] : [],
callback: (path) => { callback: async (path) => {
this.document.update({ [attr]: path }) await this.document.update({ [attr]: path })
}, },
top: this.position.top + 40, top: this.position.top + 40,
left: this.position.left + 10, left: this.position.left + 10,
+22 -31
View File
@@ -180,44 +180,44 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
await this.document.system.rollInitiative() await this.document.system.rollInitiative()
} }
static #onArmorHitPointsPlus(event, target) { static async #onArmorHitPointsPlus(event, target) {
let armorHP = this.actor.system.combat.armorHitPoints let armorHP = this.actor.system.combat.armorHitPoints
armorHP += 1 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 let armorHP = this.actor.system.combat.armorHitPoints
armorHP -= 1 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 let points = this.actor.system.divinityPoints.value
points += 1 points += 1
points = Math.min(points, this.actor.system.divinityPoints.max) 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 let points = this.actor.system.divinityPoints.value
points -= 1 points -= 1
points = Math.max(points, 0) 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 let points = this.actor.system.aetherPoints.value
points += 1 points += 1
points = Math.min(points, this.actor.system.aetherPoints.max) 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 let points = this.actor.system.aetherPoints.value
points -= 1 points -= 1
points = Math.max(points, 0) 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: [ buttons: [
{ {
action: "noDR", action: "noDR",
type: "button",
label: game.i18n.localize("LETHALFANTASY.Combat.spellNoDR"), label: game.i18n.localize("LETHALFANTASY.Combat.spellNoDR"),
icon: "fa-solid fa-wand-magic-sparkles", icon: "fa-solid fa-wand-magic-sparkles",
callback: () => 0 callback: () => 0
}, },
{ {
action: "applyDR", action: "applyDR",
type: "button",
label: game.i18n.localize("LETHALFANTASY.Combat.spellApplyDR"), label: game.i18n.localize("LETHALFANTASY.Combat.spellApplyDR"),
icon: "fa-solid fa-shield", icon: "fa-solid fa-shield",
callback: (event, button) => Number(button.form?.elements?.manualDr?.value) || 0 callback: (event, button) => Number(button.form?.elements?.manualDr?.value) || 0
}, },
{ {
action: "cancel", action: "cancel",
type: "button",
label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo"), label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo"),
callback: () => null callback: () => "cancel"
} }
], ],
rejectClose: false rejectClose: false
}) })
if (manualDR === null) return if (manualDR === null || manualDR === "cancel") return
const rollOpts = { const rollOpts = {
type: "spell-damage", type: "spell-damage",
@@ -286,28 +289,16 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
actorName: this.actor.name, actorName: this.actor.name,
actorImage: this.actor.img actorImage: this.actor.img
} }
const roll = new LethalFantasyRoll(formula, {}, rollOpts) await LethalFantasyRoll.rollSpellDamageToMessage(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
if (game?.dice3d) await game.dice3d.showForRoll(roll, game.user, true)
await roll.toMessage()
} }
static #onCreateEquipment(event, target) { static #onCreateEquipment(event, target) {
} }
_onRender(context, options) { _onRender(context, options) {
// Inputs with class `item-quantity`
const woundDescription = this.element.querySelectorAll('.wound-data') const woundDescription = this.element.querySelectorAll('.wound-data')
for (const input of woundDescription) { for (const input of woundDescription) {
input.addEventListener("change", (e) => { input.addEventListener("change", async (e) => {
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
const newValue = e.currentTarget.value const newValue = e.currentTarget.value
@@ -315,11 +306,11 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
const fieldName = e.currentTarget.dataset.name const fieldName = e.currentTarget.dataset.name
let tab = foundry.utils.duplicate(this.actor.system.hp.wounds) let tab = foundry.utils.duplicate(this.actor.system.hp.wounds)
tab[index][fieldName] = newValue tab[index][fieldName] = newValue
console.log(tab, index, fieldName, newValue) log(tab, index, fieldName, newValue)
this.actor.update({ "system.hp.wounds": tab }); 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) { async _onDrop(event) {
if (!this.isEditable || !this.isEditMode) return if (!this.isEditable || !this.isEditMode) return
const data = TextEditor.getDragEventData(event) const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event)
// Handle different data types // Handle different data types
switch (data.type) { switch (data.type) {
+82 -88
View File
@@ -73,20 +73,6 @@
], ],
"description": "Possible Lethal or Vital Magical Strike or Add D20E to Spell Attack" "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": { "skill_rolls": {
"type": "skill_auto_success", "type": "skill_auto_success",
"description": "Skill Succeeds Regardless of Opposing Roll" "description": "Skill Succeeds Regardless of Opposing Roll"
@@ -108,6 +94,20 @@
} }
], ],
"description": "Possible Flawless or Legendary Defense or Add D20E to Defense" "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": { "29": {
@@ -131,11 +131,6 @@
"amount": 1, "amount": 1,
"description": "Gain 1 Grit" "description": "Gain 1 Grit"
}, },
"arcane_spell_defense": {
"type": "gain_grit",
"amount": 1,
"description": "Gain 1 Grit"
},
"skill_rolls": { "skill_rolls": {
"type": "gain_grit", "type": "gain_grit",
"amount": 1, "amount": 1,
@@ -145,6 +140,11 @@
"type": "gain_grit", "type": "gain_grit",
"amount": 1, "amount": 1,
"description": "Gain 1 Grit" "description": "Gain 1 Grit"
},
"arcane_spell_defense": {
"type": "gain_grit",
"amount": 1,
"description": "Gain 1 Grit"
} }
}, },
"28": { "28": {
@@ -175,16 +175,16 @@
"type": "no_lethargy", "type": "no_lethargy",
"description": "No Spell Lethargy the Aether Approves of Characters Efforts" "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": { "arcane_spell_defense": {
"type": "flash_of_pain", "type": "flash_of_pain",
"duration_dice": "1D6E", "duration_dice": "1D6E",
"target": "caster", "target": "caster",
"description": "Caster Suffers Severe pain and will be under a flash of pain for 1D6E seconds" "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": { "26": {
@@ -224,12 +224,6 @@
"target": "defender", "target": "defender",
"description": "Magical Damage inflicts Flash of pain 1D6E seconds" "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": { "skill_rolls": {
"type": "bonus_dice", "type": "bonus_dice",
"dice": "D6", "dice": "D6",
@@ -239,6 +233,12 @@
"ranged_defense": { "ranged_defense": {
"type": "recover_pain", "type": "recover_pain",
"description": "Defender Recovers or ignores any flash of 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": { "20": {
@@ -310,23 +310,6 @@
], ],
"description": "Possible Vicious Application of a Magical Attack or add D12 to attack" "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": { "skill_rolls": {
"type": "bonus_flat", "type": "bonus_flat",
"amount": 20, "amount": 20,
@@ -349,6 +332,23 @@
} }
], ],
"description": "Possible 20/20 defense that avoids Any Attack Except a Lethal Strike or adds D12 to defense" "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": { "15": {
@@ -359,10 +359,10 @@
"type": "bleed" "type": "bleed"
}, },
{ {
"type": "knockback" "type": "internal_injury"
} }
], ],
"description": "Bleed, Knock-back on Hit" "description": "Bleed, Internal Injury on Hit"
}, },
"ranged_attack": { "ranged_attack": {
"type": "bleed", "type": "bleed",
@@ -372,10 +372,9 @@
"type": "counter_attack", "type": "counter_attack",
"options": [ "options": [
"kick", "kick",
"punch", "punch"
"shield_bash"
], ],
"description": "Kick, Punch or Shield Bash" "description": "Kick or Punch"
}, },
"skill_rolls": { "skill_rolls": {
"type": "bonus_flat", "type": "bonus_flat",
@@ -387,10 +386,9 @@
"type": "counter_attack", "type": "counter_attack",
"options": [ "options": [
"kick", "kick",
"punch", "punch"
"shield_bash"
], ],
"description": "Kick, Punch or Shield Bash" "description": "Kick or Punch"
} }
}, },
"13": {}, "13": {},
@@ -398,7 +396,7 @@
"melee_attack": { "melee_attack": {
"type": "flurry", "type": "flurry",
"condition": "hit_or_miss", "condition": "hit_or_miss",
"description": "Flurry Attack or Hit to Miss" "description": "Flurry Attack on Hit or Miss"
}, },
"ranged_attack": { "ranged_attack": {
"type": "double_damage_dice", "type": "double_damage_dice",
@@ -413,10 +411,10 @@
"type": "bleed" "type": "bleed"
}, },
{ {
"type": "knockback" "type": "internal_injury"
} }
], ],
"description": "Bleed, Knock-back on Hit" "description": "Bleed, Internal Injury on Hit"
}, },
"ranged_attack": { "ranged_attack": {
"type": "bleed", "type": "bleed",
@@ -426,10 +424,9 @@
"type": "counter_attack", "type": "counter_attack",
"options": [ "options": [
"kick", "kick",
"punch", "punch"
"shield_bash"
], ],
"description": "Kick, Punch or Shield Bash" "description": "Kick or Punch"
}, },
"skill_rolls": { "skill_rolls": {
"type": "bonus_flat", "type": "bonus_flat",
@@ -441,10 +438,9 @@
"type": "counter_attack", "type": "counter_attack",
"options": [ "options": [
"kick", "kick",
"punch", "punch"
"shield_bash"
], ],
"description": "Kick, Punch or Shield Bash" "description": "Kick or Punch"
} }
}, },
"8": { "8": {
@@ -464,10 +460,6 @@
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Re-Roll This Spell Attack" "description": "Mulligan, Can Re-Roll This Spell Attack"
}, },
"arcane_spell_defense": {
"type": "mulligan",
"description": "Mulligan, Can Re-Roll This Spell Defense"
},
"skill_rolls": { "skill_rolls": {
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Re-Roll This Skill roll" "description": "Mulligan, Can Re-Roll This Skill roll"
@@ -475,6 +467,10 @@
"ranged_defense": { "ranged_defense": {
"type": "mulligan", "type": "mulligan",
"description": "Mulligan, Can Choose to Re-Roll This Defense" "description": "Mulligan, Can Choose to Re-Roll This Defense"
},
"arcane_spell_defense": {
"type": "mulligan",
"description": "Mulligan, Can Re-Roll This Spell Defense"
} }
}, },
"7": { "7": {
@@ -496,10 +492,10 @@
"type": "bleed" "type": "bleed"
}, },
{ {
"type": "knockback" "type": "internal_injury"
} }
], ],
"description": "Bleed, Knock-back on Hit" "description": "Bleed, Internal Injury on Hit"
}, },
"ranged_attack": { "ranged_attack": {
"type": "bleed", "type": "bleed",
@@ -509,10 +505,9 @@
"type": "counter_attack", "type": "counter_attack",
"options": [ "options": [
"kick", "kick",
"punch", "punch"
"shield_bash"
], ],
"description": "Kick, Punch, or Shield Bash" "description": "Kick or Punch"
}, },
"skill_rolls": { "skill_rolls": {
"type": "bonus_flat", "type": "bonus_flat",
@@ -524,10 +519,9 @@
"type": "counter_attack", "type": "counter_attack",
"options": [ "options": [
"kick", "kick",
"punch", "punch"
"shield_bash"
], ],
"description": "Kick, Punch, or Shield Bash" "description": "Kick or Punch"
} }
}, },
"3": { "3": {
@@ -552,17 +546,17 @@
"multiplier": 3, "multiplier": 3,
"description": "Triple Damage on Spell Damage" "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": { "ranged_defense": {
"type": "dr_multiplier", "type": "dr_multiplier",
"multiplier": 3, "multiplier": 3,
"includes_shield": true, "includes_shield": true,
"description": "DR Tripled including Shield" "description": "DR Tripled including Shield"
},
"arcane_spell_defense": {
"type": "bonus_dice",
"dice": "D12",
"target": "spell_defense",
"description": "D12 Added to Spell Defense Modifier"
} }
}, },
"2": { "2": {
@@ -587,17 +581,17 @@
"multiplier": 2, "multiplier": 2,
"description": "Double Damage on Spell Damage" "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": { "ranged_defense": {
"type": "dr_multiplier", "type": "dr_multiplier",
"multiplier": 2, "multiplier": 2,
"includes_shield": true, "includes_shield": true,
"description": "DR Doubled including Shield" "description": "DR Doubled including Shield"
},
"arcane_spell_defense": {
"type": "bonus_dice",
"dice": "D6",
"target": "spell_defense",
"description": "D6 Added to Spell Defense Modifier"
} }
}, },
"1": { "1": {
+53 -16
View File
@@ -72,23 +72,32 @@ export default class LethalFantasyActor extends Actor {
/* *************************************************/ /* *************************************************/
async applyDamage(hpLoss) { async applyDamage(hpLoss) {
let hp = this.system.hp.value + hpLoss let hp = this.system.hp.value + hpLoss
if (hp < 0) { await this.update({ "system.hp.value": hp })
hp = 0
} }
this.update({ "system.hp.value": hp })
/* *************************************************/
getNaturalDR() {
if (this.type === "monster") {
return Number(this.system.hp?.damageResistance) || 0
}
return Number(this.system.biodata?.naturalDR) || 0
}
/* *************************************************/
getMagicDR() {
if (this.type === "monster") return 0
return Number(this.system.biodata?.magicDR) || 0
} }
/* *************************************************/ /* *************************************************/
computeDamageReduction() { computeDamageReduction() {
// Pour les monstres, utiliser hp.damageResistance et combat.damageReduction
if (this.type === "monster") { if (this.type === "monster") {
let hpDR = Number(this.system.hp?.damageResistance) || 0 let hpDR = this.getNaturalDR()
let combatDR = Number(this.system.combat?.damageReduction) || 0 let combatDR = Number(this.system.combat?.damageReduction) || 0
return hpDR + combatDR return hpDR + combatDR
} }
// Pour les personnages, utiliser biodata et items let naturalDR = this.getNaturalDR()
let naturalDR = Number(this.system.biodata?.naturalDR) || 0 let magicDR = this.getMagicDR()
let magicDR = Number(this.system.biodata?.magicDR) || 0
let armorDR = this.getArmorDR() let armorDR = this.getArmorDR()
return naturalDR + magicDR + armorDR return naturalDR + magicDR + armorDR
} }
@@ -153,8 +162,8 @@ export default class LethalFantasyActor extends Actor {
} }
/* *************************************************/ /* *************************************************/
async prepareRoll(rollType, rollKey, rollDice, defenderId, defenderTokenId, extraShieldDr = 0) { async prepareRoll(rollType, rollKey, rollDice, defenderId, defenderTokenId, extraShieldDr = 0, d30Effects = {}) {
console.log("Preparing roll", rollType, rollKey, rollDice, defenderId) log("Preparing roll", rollType, rollKey, rollDice, defenderId)
let rollTarget let rollTarget
switch (rollType) { switch (rollType) {
case "granted": case "granted":
@@ -196,10 +205,39 @@ export default class LethalFantasyActor extends Actor {
case "spell-power": case "spell-power":
case "miracle-attack": case "miracle-attack":
case "miracle-power": 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 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") { 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 const currentAether = Number(this.system.aetherPoints?.value) || 0
if (cost > currentAether) { if (cost > currentAether) {
ui.notifications.warn(`${this.name} cannot cast ${rollTarget.name}: insufficient Aether (needs ${cost}, has ${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 rollTarget.rollKey = rollKey
} }
break; break;
case "weapon-damage-small": case "weapon-damage":
case "weapon-damage-medium":
case "weapon-attack": case "weapon-attack":
case "weapon-defense": { case "weapon-defense": {
let weapon = this.items.find((i) => i.type === "weapon" && i.id === rollKey) let weapon = this.items.find((i) => i.type === "weapon" && i.id === rollKey)
@@ -264,7 +301,7 @@ export default class LethalFantasyActor extends Actor {
combat: foundry.utils.duplicate(this.system.combat), combat: foundry.utils.duplicate(this.system.combat),
isRangedAttack: weapon.system.weaponType === "ranged" isRangedAttack: weapon.system.weaponType === "ranged"
} }
if (rollType === "weapon-damage-small" || rollType === "weapon-damage-medium") { if (rollType === "weapon-damage") {
rollTarget.grantedDice = this.system.granted.damageDice rollTarget.grantedDice = this.system.granted.damageDice
} }
if (rollType === "weapon-attack") { if (rollType === "weapon-attack") {
@@ -287,7 +324,7 @@ export default class LethalFantasyActor extends Actor {
rollTarget.magicUser = this.system.biodata.magicUser rollTarget.magicUser = this.system.biodata.magicUser
rollTarget.actorModifiers = foundry.utils.duplicate(this.system.modifiers) rollTarget.actorModifiers = foundry.utils.duplicate(this.system.modifiers)
rollTarget.actorLevel = this.system.biodata.level 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)
} }
} }
+12 -5
View File
@@ -1,3 +1,5 @@
import { log } from "../utils.mjs"
/** /**
* Classe pour gérer les résultats du D30 dans Lethal Fantasy * 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.resultsTable = data.d30_dice_results
this.definitions = data.definitions this.definitions = data.definitions
console.log("D30Roll | D30 results table loaded successfully") log("D30Roll | D30 results table loaded successfully")
} catch (error) { } catch (error) {
console.error("D30Roll | Error loading D30 table:", error) console.error("D30Roll | Error loading D30 table:", error)
ui.notifications.error("Unable to load D30 results table") ui.notifications.error("Unable to load D30 results table")
@@ -131,17 +133,22 @@ export default class D30Roll {
return options.isRanged ? this.ROLL_TYPES.RANGED_DEFENSE : this.ROLL_TYPES.MELEE_DEFENSE return options.isRanged ? this.ROLL_TYPES.RANGED_DEFENSE : this.ROLL_TYPES.MELEE_DEFENSE
} }
// Spell types // Spell/Miracle types
if (externalType === "spell-attack" || externalType === "spell" || externalType === "spell-power") { if (externalType === "spell-attack" || externalType === "spell" || externalType === "spell-power"
|| externalType === "miracle-attack" || externalType === "miracle" || externalType === "miracle-power") {
return this.ROLL_TYPES.ARCANE_SPELL_ATTACK return this.ROLL_TYPES.ARCANE_SPELL_ATTACK
} }
// Skill types // Skill types
if (externalType === "skill" || externalType === "monster-skill" || if (externalType === "skill" || externalType === "monster-skill" || externalType === "challenge") {
externalType === "save" || externalType === "challenge") {
return this.ROLL_TYPES.SKILL_ROLLS return this.ROLL_TYPES.SKILL_ROLLS
} }
// Saving throw types
if (externalType === "save") {
return this.ROLL_TYPES.ARCANE_SPELL_DEFENSE
}
// If no match, return null // If no match, return null
console.warn(`D30Roll | Unknown external roll type: ${externalType}`) console.warn(`D30Roll | Unknown external roll type: ${externalType}`)
return null return null
+133 -46
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. * @returns {Promise<Object|null>} The roll result or null if the dialog was cancelled.
*/ */
static async prompt(options = {}) { static async prompt(options = {}) {
try {
let dice = "1D20" let dice = "1D20"
let maxValue = 20 let maxValue = 20
let baseFormula = "1D20" let baseFormula = "1D20"
@@ -139,7 +140,7 @@ export default class LethalFantasyRoll extends Roll {
let hasGrantedDice = false let hasGrantedDice = false
let pointBlank = false let pointBlank = false
let letItFly = false let letItFly = false
let saveSpell = false let saveSpell = game.lethalFantasy?.spellDefense ?? false
let beyondSkill = false let beyondSkill = false
let hasStaticModifier = false let hasStaticModifier = false
let hasExplode = true let hasExplode = true
@@ -285,13 +286,7 @@ export default class LethalFantasyRoll extends Roll {
let damageBonus = (options.rollTarget.weapon.system.applyStrengthDamageBonus) ? options.rollTarget.combat.damageModifier : 0 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.value = damageBonus + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.damageBonus
options.rollTarget.charModifier = 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)) { if (/NE$/i.test(dice)) {
hasMaxValue = false hasMaxValue = false
hasExplode = false hasExplode = false
@@ -364,7 +359,7 @@ export default class LethalFantasyRoll extends Roll {
dice, dice,
hasTarget: options.hasTarget, hasTarget: options.hasTarget,
modifier, modifier,
saveSpell: false, saveSpell,
favor: "none", favor: "none",
targetName, targetName,
isRangedAttack isRangedAttack
@@ -396,9 +391,11 @@ export default class LethalFantasyRoll extends Roll {
position, position,
buttons: [ buttons: [
{ {
action: "roll",
type: "button",
label: label, label: label,
callback: (event, button, dialog) => { callback: (event, button, dialog) => {
console.log("Roll context", event, button, dialog) log("Roll context", event, button, dialog)
let position = dialog?.position let position = dialog?.position
game.user.setFlag(SYSTEM.id, "roll-dialog-pos", foundry.utils.duplicate(position)) game.user.setFlag(SYSTEM.id, "roll-dialog-pos", foundry.utils.duplicate(position))
const output = Array.from(button.form.elements).reduce((obj, input) => { const output = Array.from(button.form.elements).reduce((obj, input) => {
@@ -441,7 +438,7 @@ export default class LethalFantasyRoll extends Roll {
// If the user cancels the dialog, exit // If the user cancels the dialog, exit
if (rollContext === null) return if (rollContext === null) return
console.log("rollContext", rollContext, hasGrantedDice) log("rollContext", rollContext, hasGrantedDice)
rollContext.saveSpell = saveSpell // Update fucking flag rollContext.saveSpell = saveSpell // Update fucking flag
let fullModifier = 0 let fullModifier = 0
@@ -450,7 +447,7 @@ export default class LethalFantasyRoll extends Roll {
if (hasModifier) { if (hasModifier) {
let bonus = Number(options.rollTarget.value) let bonus = Number(options.rollTarget.value)
fullModifier = rollContext.modifier === "" ? 0 : parseInt(rollContext.modifier, 10) + bonus 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) { if (Number(rollContext.attackerAim) > 0) {
fullModifier += Number(rollContext.attackerAim) fullModifier += Number(rollContext.attackerAim)
} }
@@ -529,8 +526,6 @@ export default class LethalFantasyRoll extends Roll {
rollMode: rollContext.visibility, rollMode: rollContext.visibility,
hasTarget: options.hasTarget, hasTarget: options.hasTarget,
isDamage: options.isDamage, isDamage: options.isDamage,
damageSmall: options.damageSmall,
damageMedium: options.damageMedium,
pointBlank, pointBlank,
beyondSkill, beyondSkill,
letItFly, letItFly,
@@ -560,7 +555,7 @@ export default class LethalFantasyRoll extends Roll {
if (rollContext.favor === "favor") { if (rollContext.favor === "favor") {
rollFavor = new this(baseFormula, options.data, rollData) rollFavor = new this(baseFormula, options.data, rollData)
await rollFavor.evaluate() await rollFavor.evaluate()
console.log("Rolling with favor", rollFavor) log("Rolling with favor", rollFavor)
if (game?.dice3d) { if (game?.dice3d) {
game.dice3d.showForRoll(rollFavor, game.user, true) game.dice3d.showForRoll(rollFavor, game.user, true)
} }
@@ -609,7 +604,7 @@ export default class LethalFantasyRoll extends Roll {
rollD30.total, rollD30.total,
options.rollType, options.rollType,
options.rollTarget?.weapon, options.rollTarget?.weapon,
{ isRanged: isRangedForD30 } { isRanged: isRangedForD30, isSpellSave: saveSpell }
) )
options.D30message = d30Message options.D30message = d30Message
} }
@@ -621,19 +616,20 @@ export default class LethalFantasyRoll extends Roll {
let singleDice = `1D${maxValue}` let singleDice = `1D${maxValue}`
for (let i = 0; i < rollBase.dice.length; i++) { for (let i = 0; i < rollBase.dice.length; i++) {
for (let j = 0; j < rollBase.dice[i].results.length; j++) { const dieResults = rollBase.dice[i].results
let diceResult = rollBase.dice[i].results[j].result const resultCount = dieResults.length
for (let j = 0; j < resultCount; j++) {
let diceResult = dieResults[j].result
diceResults.push({ dice: `${singleDice.toUpperCase()}`, value: diceResult }) diceResults.push({ dice: `${singleDice.toUpperCase()}`, value: diceResult })
diceSum += diceResult diceSum += diceResult
if (hasMaxValue) { if (hasMaxValue) {
while (diceResult === maxValue) { while (diceResult === maxValue) {
let r = await new Roll(baseFormula).evaluate() 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 diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${singleDice.toUpperCase()}-1`, value: diceResult - 1 }) diceResults.push({ dice: `${singleDice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1) diceSum += (diceResult - 1)
// Add to DieTerm results so DSN/Foundry display shows explosion dice
dieResults.push({ result: diceResult, active: true })
} }
} }
} }
@@ -673,6 +669,10 @@ export default class LethalFantasyRoll extends Roll {
rollBase.options.defenderId = options.defenderId rollBase.options.defenderId = options.defenderId
rollBase.options.defenderTokenId = options.defenderTokenId rollBase.options.defenderTokenId = options.defenderTokenId
rollBase.options.extraShieldDr = options.extraShieldDr || 0 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. * A hook event that fires after the roll has been made.
@@ -686,6 +686,10 @@ export default class LethalFantasyRoll extends Roll {
if (Hooks.call("fvtt-lethal-fantasy.Roll", options, rollData, rollBase) === false) return if (Hooks.call("fvtt-lethal-fantasy.Roll", options, rollData, rollBase) === false) return
return rollBase return rollBase
} finally {
// Clear one-shot flag so it doesn't leak to subsequent non-spell saves
if (game.lethalFantasy) game.lethalFantasy.spellDefense = false
}
} }
/* ***********************************************************/ /* ***********************************************************/
@@ -721,6 +725,8 @@ export default class LethalFantasyRoll extends Roll {
content, content,
buttons: [ buttons: [
{ {
action: "initiative",
type: "button",
label: label, label: label,
callback: (event, button) => { callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => { const output = Array.from(button.form.elements).reduce((obj, input) => {
@@ -787,9 +793,19 @@ export default class LethalFantasyRoll extends Roll {
let buttons = [] let buttons = []
if (currentAction) { if (currentAction) {
if (currentAction.type === "weapon") { 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({ buttons.push({
action: "roll", action: "roll",
label: "Roll progression dice", type: "button",
label: weaponLabel,
callback: (event, button) => { callback: (event, button) => {
let pos = $('#combat-action-dialog').position() let pos = $('#combat-action-dialog').position()
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos) game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
@@ -815,6 +831,7 @@ export default class LethalFantasyRoll extends Roll {
} }
buttons.push({ buttons.push({
action: "roll", action: "roll",
type: "button",
label: label, label: label,
callback: (event, button) => { callback: (event, button) => {
let pos = $('#combat-action-dialog').position() let pos = $('#combat-action-dialog').position()
@@ -826,6 +843,7 @@ export default class LethalFantasyRoll extends Roll {
} else { } else {
buttons.push({ buttons.push({
action: "roll", action: "roll",
type: "button",
label: "Select action", label: "Select action",
callback: (event, button) => { callback: (event, button) => {
let pos = $('#combat-action-dialog').position() let pos = $('#combat-action-dialog').position()
@@ -841,6 +859,7 @@ export default class LethalFantasyRoll extends Roll {
} }
buttons.push({ buttons.push({
action: "cancel", action: "cancel",
type: "button",
label: "Other action, not listed here", label: "Other action, not listed here",
callback: (event, button) => { callback: (event, button) => {
let pos = $('#combat-action-dialog').position() let pos = $('#combat-action-dialog').position()
@@ -859,12 +878,12 @@ export default class LethalFantasyRoll extends Roll {
rejectClose: false // Click on Close button will not launch an error 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 action is cancelled, exit
if (rollContext === null || rollContext === "cancel") { if (rollContext === null || rollContext === "cancel") {
await combatant.setFlag(SYSTEM.id, "currentAction", "") await combatant.setFlag(SYSTEM.id, "currentAction", "")
let message = `${combatant.name} : Other action, progression reset` 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 return
} }
@@ -894,12 +913,36 @@ export default class LethalFantasyRoll extends Roll {
actionItem.progressionCount = firstActionTaken ? 1 : (combatant.actor.system.combat?.combatProgressionStart ?? 1) actionItem.progressionCount = firstActionTaken ? 1 : (combatant.actor.system.combat?.combatProgressionStart ?? 1)
if (!firstActionTaken) await combatant.setFlag(SYSTEM.id, "firstActionTaken", true) if (!firstActionTaken) await combatant.setFlag(SYSTEM.id, "firstActionTaken", true)
actionItem.rangedMode = rangedMode 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.castingTime = 1
actionItem.spellStatus = "castingTime" actionItem.spellStatus = "castingTime"
// Set the flag on the combatant // Set the flag on the combatant
await combatant.setFlag(SYSTEM.id, "currentAction", actionItem) await combatant.setFlag(SYSTEM.id, "currentAction", actionItem)
let message = `${combatant.name} action : ${selectedItem.name}, start rolling progression dice or casting time` 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 rollContext = (actionItem.type === "weapon") ? "rollProgressionDice" : "rollLethargyDice" // Set the roll context to rollProgressionDice
currentAction = actionItem currentAction = actionItem
} }
@@ -910,13 +953,14 @@ export default class LethalFantasyRoll extends Roll {
let time = currentAction.type === "spell" ? currentAction.system.castingTime : currentAction.system.prayerTime let time = currentAction.type === "spell" ? currentAction.system.castingTime : currentAction.system.prayerTime
if (currentAction.castingTime < time) { if (currentAction.castingTime < time) {
let message = `Casting time : ${currentAction.name}, count : ${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 currentAction.castingTime += 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction)) await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
return return
} else { } else {
let message = `Spell/Miracle ${currentAction.name} ready to be cast on next second !` // Last counting second — announce ready and transition immediately (no extra second consumed)
ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) }) 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.castingTime = 1
currentAction.spellStatus = "toBeCasted" currentAction.spellStatus = "toBeCasted"
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction)) await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
@@ -956,7 +1000,7 @@ export default class LethalFantasyRoll extends Roll {
isLethargy: true 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 // Update the combatant progression count
await combatant.setFlag(SYSTEM.id, "currentAction", "") await combatant.setFlag(SYSTEM.id, "currentAction", "")
// Display the action selection window again // Display the action selection window again
@@ -976,7 +1020,7 @@ export default class LethalFantasyRoll extends Roll {
isLethargy: true isLethargy: true
} }
) )
ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) }) await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
} }
} }
} }
@@ -988,18 +1032,18 @@ export default class LethalFantasyRoll extends Roll {
let split = toSplit.split("+") let split = toSplit.split("+")
currentAction.rangedLoad = Number(split[0]) || 0 currentAction.rangedLoad = Number(split[0]) || 0
formula = split[1] formula = split[1]
console.log("Ranged Mode", currentAction.rangedMode, currentAction.rangedLoad, formula) log("Ranged Mode", currentAction.rangedMode, currentAction.rangedLoad, formula)
} }
// Range weapon loading // Range weapon loading
if (!currentAction.weaponLoaded && currentAction.rangedLoad) { 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}` 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 currentAction.progressionCount += 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction)) await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
} else { } else {
let message = `Ranged weapon ${currentAction.name} is loaded !` 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.weaponLoaded = true
currentAction.progressionCount = 1 currentAction.progressionCount = 1
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction)) await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
@@ -1031,13 +1075,13 @@ export default class LethalFantasyRoll extends Roll {
rollResult: roll.total 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", "") await combatant.setFlag(SYSTEM.id, "currentAction", "")
combatant.actor.prepareRoll(currentAction.type === "weapon" ? "weapon-attack" : "spell-attack", currentAction._id) combatant.actor.prepareRoll(currentAction.type === "weapon" ? "weapon-attack" : "spell-attack", currentAction._id)
} else { } else {
// Notify that the player cannot act now with a chat message // Notify that the player cannot act now with a chat message
currentAction.progressionCount += 1 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( const messageContent = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs", "systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
{ {
@@ -1048,7 +1092,7 @@ export default class LethalFantasyRoll extends Roll {
progressionCount: currentAction.progressionCount progressionCount: currentAction.progressionCount
} }
) )
ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) }) await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
} }
} }
} }
@@ -1088,6 +1132,8 @@ export default class LethalFantasyRoll extends Roll {
content, content,
buttons: [ buttons: [
{ {
action: "rangeDefense",
type: "button",
label: label, label: label,
callback: (event, button) => { callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => { const output = Array.from(button.form.elements).reduce((obj, input) => {
@@ -1104,7 +1150,7 @@ export default class LethalFantasyRoll extends Roll {
// If the user cancels the dialog, exit // If the user cancels the dialog, exit
if (rollContext === null) return if (rollContext === null) return
console.log("RollContext", rollContext) log("RollContext", rollContext)
// Add disfavor/favor option if point blank range // Add disfavor/favor option if point blank range
if (rollContext.range === "pointblank") { if (rollContext.range === "pointblank") {
rollContext.movement = rollContext.movement.replace("kh", "") rollContext.movement = rollContext.movement.replace("kh", "")
@@ -1172,6 +1218,7 @@ export default class LethalFantasyRoll extends Roll {
diceResult = r.dice[0].results[0].result diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 }) diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1) diceSum += (diceResult - 1)
rollBase.dice[0].results.push({ result: diceResult, active: true })
} }
if (fullModifier !== 0) { if (fullModifier !== 0) {
diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total }) diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total })
@@ -1247,6 +1294,8 @@ export default class LethalFantasyRoll extends Roll {
content, content,
buttons: [ buttons: [
{ {
action: "rangedAttack",
type: "button",
label, label,
callback: (event, button) => { callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => { const output = Array.from(button.form.elements).reduce((obj, input) => {
@@ -1332,6 +1381,7 @@ export default class LethalFantasyRoll extends Roll {
diceResult = r.dice[0].results[0].result diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 }) diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1) diceSum += (diceResult - 1)
rollBase.dice[0].results.push({ result: diceResult, active: true })
} }
if (fullModifier !== 0) { if (fullModifier !== 0) {
@@ -1382,10 +1432,8 @@ export default class LethalFantasyRoll extends Roll {
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-attack")}` return `${game.i18n.localize("LETHALFANTASY.Label.weapon-attack")}`
case "weapon-defense": case "weapon-defense":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-defense")}` return `${game.i18n.localize("LETHALFANTASY.Label.weapon-defense")}`
case "weapon-damage-small": case "weapon-damage":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-damage-small")}` return `${game.i18n.localize("LETHALFANTASY.Label.weapon-damage")}`
case "weapon-damage-medium":
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-damage-medium")}`
case "spell": case "spell":
case "spell-attack": case "spell-attack":
case "spell-power": case "spell-power":
@@ -1402,7 +1450,7 @@ export default class LethalFantasyRoll extends Roll {
/** @override */ /** @override */
async render(chatOptions = {}) { async render(chatOptions = {}) {
let chatData = await this._getChatCardData(chatOptions.isPrivate) 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) return await foundry.applications.handlebars.renderTemplate(this.constructor.CHAT_TEMPLATE, chatData)
} }
@@ -1440,16 +1488,15 @@ export default class LethalFantasyRoll extends Roll {
// Récupérer les informations de l'arme pour les attaques réussies // Récupérer les informations de l'arme pour les attaques réussies
let weaponDamageOptions = null 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) { if (this.type === "weapon-attack" && this.rollTarget?.weapon) {
const weapon = this.rollTarget.weapon const weapon = this.rollTarget.weapon
weaponDamageOptions = { weaponDamageOptions = {
weaponId: weapon._id || weapon.id, weaponId: weapon.id,
weaponName: weapon.name, weaponName: weapon.name,
damageS: weapon.system?.damage?.damageS,
damageM: weapon.system?.damage?.damageM damageM: weapon.system?.damage?.damageM
} }
console.log("Weapon damage options:", weaponDamageOptions) log("Weapon damage options:", weaponDamageOptions)
} else if (this.type === "monster-attack" && this.rollTarget) { } else if (this.type === "monster-attack" && this.rollTarget) {
weaponDamageOptions = { weaponDamageOptions = {
weaponId: this.rollTarget.rollKey, weaponId: this.rollTarget.rollKey,
@@ -1458,7 +1505,7 @@ export default class LethalFantasyRoll extends Roll {
damageModifier: this.rollTarget.damageModifier, damageModifier: this.rollTarget.damageModifier,
isMonster: true isMonster: true
} }
console.log("Monster damage options:", weaponDamageOptions) log("Monster damage options:", weaponDamageOptions)
} }
const cardData = { const cardData = {
@@ -1529,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" 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}', '=');`
: `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") this.createMacro(slot, rollName, rollCommand, "icons/svg/d20-grey.svg")
break break
case "rollDamage": case "rollDamage":
const weapon = game.actors.get(dropData.actorId).items.get(dropData.rollTarget) 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 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) this.createMacro(slot, rollDamageName, rollDamageCommand, weapon.img)
break break
case "rollAttack": case "rollAttack":
const rollAttackCommand = `game.actors.get('${dropData.actorId}').system.roll('${dropData.rollValue}', '${dropData.rollTarget}');` 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") this.createMacro(slot, rollAttackName, rollAttackCommand, "icons/svg/d20-grey.svg")
break break
@@ -57,7 +57,7 @@ export class Macros {
/** /**
* Create a macro * 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 {*} slot
* @param {*} name * @param {*} name
* @param {*} command * @param {*} command
@@ -72,7 +72,7 @@ export class Macros {
type: "script", type: "script",
img: img, img: img,
command: command, command: command,
flags: { "tenebris.macro": true }, flags: { "lethalFantasy.macro": true },
}, },
{ displaySheet: false }, { displaySheet: false },
) )
+7 -3
View File
@@ -65,7 +65,7 @@ export default class LethalFantasyCharacter extends foundry.abstract.TypeDataMod
} }
schema.hp = new fields.SchemaField({ 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 }), max: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }),
painDamage: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }), painDamage: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }),
wounds: new fields.ArrayField(new fields.SchemaField(woundFieldSchema), { 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 (=). * @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. * @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 const hasTarget = false
let roll = await LethalFantasyRoll.prompt({ let roll = await LethalFantasyRoll.prompt({
rollType, rollType,
@@ -293,7 +293,11 @@ export default class LethalFantasyCharacter extends foundry.abstract.TypeDataMod
target: false, target: false,
defenderId, defenderId,
defenderTokenId, defenderTokenId,
extraShieldDr extraShieldDr,
damageTier: rollTarget.damageTier || "standard",
d30Bleed: d30Effects.d30Bleed || false,
d30DamageMultiplier: d30Effects.d30DamageMultiplier || 1,
d30DrMultiplier: d30Effects.d30DrMultiplier || 1
}) })
if (!roll) return null if (!roll) return null
+31 -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({ 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 }), average: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }),
max: 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 }), damageResistance: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }),
painDamage: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }) painDamage: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 })
}) })
@@ -164,7 +182,7 @@ 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 (=). * @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. * @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 const hasTarget = false
// Ranged monster defense uses the ranged defense dialog (movement, range, size modifiers) // Ranged monster defense uses the ranged defense dialog (movement, range, size modifiers)
@@ -192,14 +210,18 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
target: false, target: false,
defenderId, defenderId,
defenderTokenId, defenderTokenId,
extraShieldDr extraShieldDr,
damageTier: rollTarget.damageTier || "standard",
d30Bleed: d30Effects.d30Bleed || false,
d30DamageMultiplier: d30Effects.d30DamageMultiplier || 1,
d30DrMultiplier: d30Effects.d30DrMultiplier || 1
}) })
if (!roll) return null if (!roll) return null
await roll.toMessage({}, { messageMode: roll.options.rollMode }) 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 let rollTarget
switch (rollType) { switch (rollType) {
case "monster-attack": case "monster-attack":
@@ -252,8 +274,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
}, { messageMode: roll.options.rollMode ?? game.settings.get("core", "rollMode") }) }, { messageMode: roll.options.rollMode ?? game.settings.get("core", "rollMode") })
return return
} }
case "weapon-damage-small": case "weapon-damage":
case "weapon-damage-medium":
case "weapon-attack": case "weapon-attack":
case "weapon-defense": { case "weapon-defense": {
let weapon = this.actor.items.find((i) => i.type === "weapon" && i.id === rollKey) let weapon = this.actor.items.find((i) => i.type === "weapon" && i.id === rollKey)
@@ -300,7 +321,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
// In all cases // In all cases
if (rollTarget) { if (rollTarget) {
rollTarget.tokenId = tokenId rollTarget.tokenId = tokenId
await this.roll(rollType, rollTarget, defenderId, defenderTokenId, extraShieldDr) await this.roll(rollType, rollTarget, defenderId, defenderTokenId, extraShieldDr, d30Effects)
} }
} }
@@ -362,7 +383,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
rollResult: roll.total 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 let token = combatant?.token
this.prepareMonsterRoll("monster-attack", key, undefined, token?.id) this.prepareMonsterRoll("monster-attack", key, undefined, token?.id)
if (token?.object) { if (token?.object) {
@@ -386,7 +407,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
rollResult: roll.total 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 let token = combatant?.token
this.prepareMonsterRoll("monster-attack-hth", key, undefined, token?.id) this.prepareMonsterRoll("monster-attack-hth", key, undefined, token?.id)
if (token?.object) { if (token?.object) {
@@ -405,7 +426,7 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
rollResult: roll.total 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.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.memorized = new fields.BooleanField({ required: true, initial: false })
schema.components = new fields.SchemaField({ schema.components = new fields.SchemaField({
+6 -5
View File
@@ -1,6 +1,7 @@
import { SYSTEM } from "../config/system.mjs" 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() { static defineSchema() {
const fields = foundry.data.fields const fields = foundry.data.fields
const schema = {} const schema = {}
@@ -45,9 +46,9 @@ export default class LethalFantasySkill extends foundry.abstract.TypeDataModel {
}) })
schema.bonuses = new fields.SchemaField({ schema.bonuses = new fields.SchemaField({
attackBonus: 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, min: 0 }), damageBonus: new fields.NumberField({ ...requiredInteger, required: true, initial: 0 }),
defenseBonus: new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 }) defenseBonus: new fields.NumberField({ ...requiredInteger, required: true, initial: 0 })
}) })
schema.encLoad = new fields.NumberField({ required: true, initial: 0, min: 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"] static LOCALIZATION_PREFIXES = ["LETHALFANTASY.Weapon"]
get weaponCategory() { get weaponCategory() {
return game.i18n.localize(CATEGORY[this.weaponType].label) return game.i18n.localize(WEAPON_TYPE[this.weaponType] || this.weaponType)
} }
} }
+543 -48
View File
@@ -1,8 +1,9 @@
import { SYSTEM } from "./config/system.mjs" import { SYSTEM } from "./config/system.mjs"
// Map temporaire pour stocker les données d'attaque en attente de défense export function log(...args) {
if (!globalThis.pendingDefenses) { if (game?.settings?.get(game.system.id, "debug")) {
globalThis.pendingDefenses = new Map() console.log(...args)
}
} }
export default class LethalFantasyUtils { export default class LethalFantasyUtils {
@@ -27,7 +28,12 @@ export default class LethalFantasyUtils {
/* -------------------------------------------- */ /* -------------------------------------------- */
static setHookListeners() { 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) // HP Loss Button (existing)
const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {}) const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {})
$(html).find('div.left').append(lossHPButton); $(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('.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(); event.preventDefault();
let hpLoss = event.currentTarget.dataset.hpValue; let hpLoss = event.currentTarget.dataset.hpValue;
if (token) { await hudActor.applyDamage(Number(hpLoss));
let tokenFull = canvas.tokens.placeables.find(t => t.id === token._id);
console.log(tokenFull, token)
let actor = tokenFull.actor;
actor.applyDamage(Number(hpLoss));
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active'); $(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')[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.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled'); $(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.remove('hp-loss-hud-active');
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled'); $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
}
}) })
// HP Gain Button (new) // 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('.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(); event.preventDefault();
let hpGain = event.currentTarget.dataset.hpValue; let hpGain = event.currentTarget.dataset.hpValue;
if (token) { await hudActor.applyDamage(Number(hpGain)); // Positive value to add HP
let tokenFull = canvas.tokens.placeables.find(t => t.id === token._id); // Clear bleeding wounds on heal — regardless of heal amount, any
console.log(tokenFull, token) // healing is enough to stop bleeding (field dressing / magic / rest).
let actor = tokenFull.actor; const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || [])
actor.applyDamage(Number(hpGain)); // Positive value to add HP 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.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled'); $(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.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled'); $(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.remove('hp-gain-hud-active');
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled'); $(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 = {}) { static async handleSocketEvent(msg = {}) {
console.log(`handleSocketEvent !`, msg) log(`handleSocketEvent !`, msg)
let actor let actor
switch (msg.type) { switch (msg.type) {
case "applyDamage": case "applyDamage":
@@ -120,18 +152,18 @@ export default class LethalFantasyUtils {
? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor ? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
: (game.combat?.combatants?.find(c => c.actorId === msg.actorId)?.actor : (game.combat?.combatants?.find(c => c.actorId === msg.actorId)?.actor
?? game.actors.get(msg.actorId)) ?? game.actors.get(msg.actorId))
if (actor) actor.applyDamage(msg.damage) if (actor) await actor.applyDamage(msg.damage)
} }
break break
case "rollInitiative": case "rollInitiative":
if (msg.userId && msg.userId !== game.user.id) break if (msg.userId && msg.userId !== game.user.id) break
actor = game.actors.get(msg.actorId) actor = game.actors.get(msg.actorId)
actor.system.rollInitiative(msg.combatId, msg.combatantId) await actor.system.rollInitiative(msg.combatId, msg.combatantId)
break break
case "rollProgressionDice": case "rollProgressionDice":
if (msg.userId && msg.userId !== game.user.id) break if (msg.userId && msg.userId !== game.user.id) break
actor = game.actors.get(msg.actorId) 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 break
case "requestDefense": case "requestDefense":
// Vérifier si le message est destiné à cet utilisateur // Vérifier si le message est destiné à cet utilisateur
@@ -145,6 +177,26 @@ export default class LethalFantasyUtils {
LethalFantasyUtils.handleAttackerGritOffer(msg) LethalFantasyUtils.handleAttackerGritOffer(msg)
} }
break 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) { static async showDefenseRequest(msg) {
const attackerName = msg.attackerName const attackerName = msg.attackerName
@@ -235,7 +506,7 @@ export default class LethalFantasyUtils {
const isMonster = defender.type === "monster" 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 // Spell/miracle attacks use saving throws instead of weapon defense
const isSpellAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack" const isSpellAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
@@ -269,6 +540,7 @@ export default class LethalFantasyUtils {
buttons: [ buttons: [
{ {
action: "rollSave", action: "rollSave",
type: "button",
label: "Roll Save", label: "Roll Save",
icon: "fa-solid fa-person-running", icon: "fa-solid fa-person-running",
callback: (event, button) => button.form.elements.saveKey.value, callback: (event, button) => button.form.elements.saveKey.value,
@@ -279,6 +551,7 @@ export default class LethalFantasyUtils {
if (result) { if (result) {
game.lethalFantasy = game.lethalFantasy || {} game.lethalFantasy = game.lethalFantasy || {}
game.lethalFantasy.spellDefense = true // pré-cocher "Save against spell" dans le dialog
game.lethalFantasy.nextDefenseData = { game.lethalFantasy.nextDefenseData = {
attackerId, attackerId,
attackRoll, attackRoll,
@@ -290,13 +563,15 @@ export default class LethalFantasyUtils {
attackD30result, attackD30result,
attackD30message, attackD30message,
attackRerollContext, attackRerollContext,
attackNaturalRoll: msg.attackNaturalRoll,
damageTier: msg.damageTier,
defenderId: defender.id, defenderId: defender.id,
defenderTokenId defenderTokenId
} }
if (isMonster) { if (isMonster) {
defender.system.prepareMonsterRoll("save", result) await defender.system.prepareMonsterRoll("save", result)
} else { } else {
defender.prepareRoll("save", result) await defender.prepareRoll("save", result)
} }
} }
return return
@@ -339,6 +614,8 @@ export default class LethalFantasyUtils {
content, content,
buttons: [ buttons: [
{ {
action: "rangeDefense",
type: "button",
label: "Roll Defense", label: "Roll Defense",
icon: "fa-solid fa-shield", icon: "fa-solid fa-shield",
callback: (event, button, dialog) => { callback: (event, button, dialog) => {
@@ -365,12 +642,14 @@ export default class LethalFantasyUtils {
attackD30result, attackD30result,
attackD30message, attackD30message,
attackRerollContext, attackRerollContext,
attackNaturalRoll: msg.attackNaturalRoll,
damageTier: msg.damageTier,
defenderId: defender.id, defenderId: defender.id,
defenderTokenId, defenderTokenId,
isRanged: msg.isRanged isRanged: msg.isRanged
} }
defender.system.prepareMonsterRoll("monster-defense", result) await defender.system.prepareMonsterRoll("monster-defense", result)
} }
return return
} }
@@ -397,6 +676,7 @@ export default class LethalFantasyUtils {
attackD30result, attackD30result,
attackD30message, attackD30message,
attackRerollContext, attackRerollContext,
damageTier: msg.damageTier,
defenderId: defender.id, defenderId: defender.id,
defenderTokenId, defenderTokenId,
isRanged: true isRanged: true
@@ -443,6 +723,8 @@ export default class LethalFantasyUtils {
content, content,
buttons: [ buttons: [
{ {
action: "defenseRoll",
type: "button",
label: "Roll Defense", label: "Roll Defense",
icon: "fa-solid fa-shield", icon: "fa-solid fa-shield",
callback: (event, button, dialog) => { callback: (event, button, dialog) => {
@@ -469,14 +751,16 @@ export default class LethalFantasyUtils {
attackD30result, attackD30result,
attackD30message, attackD30message,
attackRerollContext, attackRerollContext,
attackNaturalRoll: msg.attackNaturalRoll,
damageTier: msg.damageTier,
defenderId: defender.id, defenderId: defender.id,
defenderTokenId, defenderTokenId,
isRanged: msg.isRanged 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" 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() { static getCombatBonusDiceChoices() {
return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"] return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"]
@@ -542,6 +1010,7 @@ export default class LethalFantasyUtils {
buttons: [ buttons: [
{ {
action: "roll", action: "roll",
type: "button",
label: "Roll Bonus Die", label: "Roll Bonus Die",
icon: "fa-solid fa-dice", icon: "fa-solid fa-dice",
callback: (event, button) => { callback: (event, button) => {
@@ -551,6 +1020,7 @@ export default class LethalFantasyUtils {
}, },
{ {
action: "cancel", action: "cancel",
type: "button",
label: "Cancel", label: "Cancel",
icon: "fa-solid fa-xmark", icon: "fa-solid fa-xmark",
callback: () => null callback: () => null
@@ -598,6 +1068,7 @@ export default class LethalFantasyUtils {
buttons: [ buttons: [
{ {
action: "roll", action: "roll",
type: "button",
label: "Roll Shield", label: "Roll Shield",
icon: "fa-solid fa-shield", icon: "fa-solid fa-shield",
callback: (event, button) => { callback: (event, button) => {
@@ -611,6 +1082,7 @@ export default class LethalFantasyUtils {
}, },
{ {
action: "cancel", action: "cancel",
type: "button",
label: "Cancel", label: "Cancel",
icon: "fa-solid fa-xmark", icon: "fa-solid fa-xmark",
callback: () => null callback: () => null
@@ -681,6 +1153,7 @@ export default class LethalFantasyUtils {
if (currentGrit > 0) { if (currentGrit > 0) {
buttons.push({ buttons.push({
action: "grit", action: "grit",
type: "button",
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
icon: "fa-solid fa-fist-raised", icon: "fa-solid fa-fist-raised",
callback: () => "grit" callback: () => "grit"
@@ -690,6 +1163,7 @@ export default class LethalFantasyUtils {
if (currentLuck > 0) { if (currentLuck > 0) {
buttons.push({ buttons.push({
action: "luck", action: "luck",
type: "button",
label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
icon: "fa-solid fa-clover", icon: "fa-solid fa-clover",
callback: () => "luck" callback: () => "luck"
@@ -698,6 +1172,7 @@ export default class LethalFantasyUtils {
buttons.push({ buttons.push({
action: "continue", action: "continue",
type: "button",
label: "Continue (no bonus)", label: "Continue (no bonus)",
icon: "fa-solid fa-forward", icon: "fa-solid fa-forward",
callback: () => "continue" callback: () => "continue"
@@ -773,12 +1248,14 @@ export default class LethalFantasyUtils {
const buttons = [ const buttons = [
{ {
action: "grit", action: "grit",
type: "button",
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
icon: "fa-solid fa-fist-raised", icon: "fa-solid fa-fist-raised",
callback: () => "grit" callback: () => "grit"
}, },
{ {
action: "continue", action: "continue",
type: "button",
label: "Continue (no bonus)", label: "Continue (no bonus)",
icon: "fa-solid fa-forward", icon: "fa-solid fa-forward",
callback: () => "continue" callback: () => "continue"
@@ -832,30 +1309,39 @@ export default class LethalFantasyUtils {
/* -------------------------------------------- */ /* -------------------------------------------- */
static async compareAttackDefense(data) { 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 outcome = data.outcome || (data.attackRoll > data.defenseRoll ? "hit" : "miss")
const isAttackWin = outcome !== "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 = "" let damageButton = ""
if (isAttackWin && (data.attackWeaponId || data.attackRollKey)) { 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 // Déterminer le type de dégâts à lancer
if (data.attackRollType === "weapon-attack") { if (data.attackRollType === "weapon-attack") {
damageButton = ` damageButton = `
<div class="attack-result-damage"> <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="small"> <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-d6"></i> Damage (Small) <i class="fa-solid fa-dice-d20"></i> Damage
</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)
</button> </button>
</div> </div>
` `
} else if (data.attackRollType === "monster-attack") { } else if (data.attackRollType === "monster-attack") {
damageButton = ` damageButton = `
<div class="attack-result-damage single-btn"> <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 <i class="fa-solid fa-burst"></i> Damage
</button> </button>
</div> </div>
@@ -863,11 +1349,13 @@ export default class LethalFantasyUtils {
} else if (data.attackRollType === "spell-attack" || data.attackRollType === "miracle-attack") { } else if (data.attackRollType === "spell-attack" || data.attackRollType === "miracle-attack") {
const attacker = game.actors.get(data.attackerId) const attacker = game.actors.get(data.attackerId)
const spell = attacker?.items.get(data.attackWeaponId || data.attackRollKey) const spell = attacker?.items.get(data.attackWeaponId || data.attackRollKey)
const tiers = [ const chosenTier = data.damageTier || "standard"
{ formula: spell?.system?.damageDice, label: "Standard" }, const allTiers = [
{ formula: spell?.system?.damageDiceOverpowered, label: "Overpowered" }, { id: "standard", formula: spell?.system?.damageDice, label: "Standard" },
{ formula: spell?.system?.damageDiceOverpowered2, label: "Overpowered 2" }, { id: "overpowered", formula: spell?.system?.damageDiceOverpowered, label: "Overpowered" },
].filter(t => t.formula) { id: "overpowered2", formula: spell?.system?.damageDiceOverpowered2, label: "Overpowered 2" },
]
const tiers = allTiers.filter(t => t.id === chosenTier && t.formula)
if (tiers.length) { if (tiers.length) {
const buttons = tiers.map(t => { const buttons = tiers.map(t => {
const escapedFormula = Handlebars.escapeExpression(t.formula) const escapedFormula = Handlebars.escapeExpression(t.formula)
@@ -877,7 +1365,10 @@ export default class LethalFantasyUtils {
data-defender-id="${data.defenderId}" data-defender-id="${data.defenderId}"
data-defender-token-id="${data.defenderTokenId || ""}" data-defender-token-id="${data.defenderTokenId || ""}"
data-damage-type="spell" 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}) <i class="fa-solid fa-wand-magic-sparkles"></i> ${t.label} (${escapedFormula})
</button>` </button>`
}).join("") }).join("")
@@ -918,12 +1409,12 @@ export default class LethalFantasyUtils {
</div> </div>
` `
console.log("Creating combat result message...") log("Creating combat result message...")
await ChatMessage.create({ await ChatMessage.create({
content: resultMessage, content: resultMessage,
speaker: { alias: "Combat System" } speaker: { alias: "Combat System" }
}) })
console.log("Combat result message created!") log("Combat result message created!")
} }
static registerHandlebarsHelpers() { static registerHandlebarsHelpers() {
@@ -1060,7 +1551,7 @@ export default class LethalFantasyUtils {
return eval(expr); return eval(expr);
}) })
Handlebars.registerHelper('isOwnerOrGM', function (actor) { 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; return actor.isOwner || game.isGM;
}) })
Handlebars.registerHelper('upperCase', function (text) { Handlebars.registerHelper('upperCase', function (text) {
@@ -1155,21 +1646,25 @@ export default class LethalFantasyUtils {
buttons: [ buttons: [
{ {
action: "noDR", action: "noDR",
type: "button",
label: "No DR", label: "No DR",
callback: () => ({ drType: "none", damage: damageTotal }) callback: () => ({ drType: "none", damage: damageTotal })
}, },
{ {
action: "armorDR", action: "armorDR",
type: "button",
label: "With Armor DR", label: "With Armor DR",
callback: () => ({ drType: "armor", damage: Math.max(0, damageTotal - armorDR) }) callback: () => ({ drType: "armor", damage: Math.max(0, damageTotal - armorDR) })
}, },
{ {
action: "allDR", action: "allDR",
type: "button",
label: "With Armor + Shield DR", label: "With Armor + Shield DR",
callback: () => ({ drType: "all", damage: Math.max(0, damageTotal - totalDR) }) callback: () => ({ drType: "all", damage: Math.max(0, damageTotal - totalDR) })
}, },
{ {
action: "cancel", action: "cancel",
type: "button",
label: "Cancel", label: "Cancel",
callback: () => null callback: () => null
} }
@@ -1198,7 +1693,7 @@ export default class LethalFantasyUtils {
} }
) )
ChatMessage.create({ await ChatMessage.create({
user: game.user.id, user: game.user.id,
speaker: { alias: targetActor.name }, speaker: { alias: targetActor.name },
mode: "gm", mode: "gm",
-1
View File
@@ -1 +0,0 @@
MANIFEST-000619
View File
-8
View File
@@ -1,8 +0,0 @@
2026/05/24-09:22:43.041326 7fdfa95ff6c0 Recovering log #617
2026/05/24-09:22:43.083988 7fdfa95ff6c0 Delete type=3 #615
2026/05/24-09:22:43.084045 7fdfa95ff6c0 Delete type=0 #617
2026/05/24-09:42:00.304351 7fdf5affd6c0 Level-0 table #622: started
2026/05/24-09:42:00.304423 7fdf5affd6c0 Level-0 table #622: 0 bytes OK
2026/05/24-09:42:00.311333 7fdf5affd6c0 Delete type=0 #620
2026/05/24-09:42:00.334170 7fdf5affd6c0 Manual compaction at level-0 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
2026/05/24-09:42:00.334370 7fdf5affd6c0 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/23-18:45:10.214922 7fa233fff6c0 Recovering log #613
2026/05/23-18:45:10.223944 7fa233fff6c0 Delete type=3 #611
2026/05/23-18:45:10.223997 7fa233fff6c0 Delete type=0 #613
2026/05/23-19:09:56.190877 7fa2327fc6c0 Level-0 table #618: started
2026/05/23-19:09:56.190922 7fa2327fc6c0 Level-0 table #618: 0 bytes OK
2026/05/23-19:09:56.224359 7fa2327fc6c0 Delete type=0 #616
2026/05/23-19:09:56.339160 7fa2327fc6c0 Manual compaction at level-0 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
2026/05/23-19:09:56.339194 7fa2327fc6c0 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-000616
View File
-8
View File
@@ -1,8 +0,0 @@
2026/05/24-09:22:43.091828 7fdfa8dfe6c0 Recovering log #614
2026/05/24-09:22:43.131041 7fdfa8dfe6c0 Delete type=3 #612
2026/05/24-09:22:43.131104 7fdfa8dfe6c0 Delete type=0 #614
2026/05/24-09:42:00.326388 7fdf5affd6c0 Level-0 table #619: started
2026/05/24-09:42:00.326417 7fdf5affd6c0 Level-0 table #619: 0 bytes OK
2026/05/24-09:42:00.334051 7fdf5affd6c0 Delete type=0 #617
2026/05/24-09:42:00.334205 7fdf5affd6c0 Manual compaction at level-0 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
2026/05/24-09:42:00.334364 7fdf5affd6c0 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/23-18:45:10.228083 7fa232ffd6c0 Recovering log #610
2026/05/23-18:45:10.236935 7fa232ffd6c0 Delete type=3 #608
2026/05/23-18:45:10.236958 7fa232ffd6c0 Delete type=0 #610
2026/05/23-19:09:56.261648 7fa2327fc6c0 Level-0 table #615: started
2026/05/23-19:09:56.261672 7fa2327fc6c0 Level-0 table #615: 0 bytes OK
2026/05/23-19:09:56.304021 7fa2327fc6c0 Delete type=0 #613
2026/05/23-19:09:56.339181 7fa2327fc6c0 Manual compaction at level-0 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
2026/05/23-19:09:56.339208 7fa2327fc6c0 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-000621
View File
-8
View File
@@ -1,8 +0,0 @@
2026/05/24-09:22:42.993966 7fdfa8dfe6c0 Recovering log #619
2026/05/24-09:22:43.035467 7fdfa8dfe6c0 Delete type=3 #617
2026/05/24-09:22:43.035518 7fdfa8dfe6c0 Delete type=0 #619
2026/05/24-09:42:00.319082 7fdf5affd6c0 Level-0 table #624: started
2026/05/24-09:42:00.319109 7fdf5affd6c0 Level-0 table #624: 0 bytes OK
2026/05/24-09:42:00.326261 7fdf5affd6c0 Delete type=0 #622
2026/05/24-09:42:00.334196 7fdf5affd6c0 Manual compaction at level-0 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
2026/05/24-09:42:00.334355 7fdf5affd6c0 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/23-18:45:10.200781 7fa2337fe6c0 Recovering log #615
2026/05/23-18:45:10.211345 7fa2337fe6c0 Delete type=3 #613
2026/05/23-18:45:10.211416 7fa2337fe6c0 Delete type=0 #615
2026/05/23-19:09:56.304132 7fa2327fc6c0 Level-0 table #620: started
2026/05/23-19:09:56.304162 7fa2327fc6c0 Level-0 table #620: 0 bytes OK
2026/05/23-19:09:56.339034 7fa2327fc6c0 Delete type=0 #618
2026/05/23-19:09:56.339189 7fa2327fc6c0 Manual compaction at level-0 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
2026/05/23-19:09:56.339203 7fa2327fc6c0 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-000316
-8
View File
@@ -1,8 +0,0 @@
2026/05/24-09:22:43.194643 7fdfa8dfe6c0 Recovering log #314
2026/05/24-09:22:43.231409 7fdfa8dfe6c0 Delete type=3 #312
2026/05/24-09:22:43.231470 7fdfa8dfe6c0 Delete type=0 #314
2026/05/24-09:42:00.341818 7fdf5affd6c0 Level-0 table #319: started
2026/05/24-09:42:00.341853 7fdf5affd6c0 Level-0 table #319: 0 bytes OK
2026/05/24-09:42:00.348538 7fdf5affd6c0 Delete type=0 #317
2026/05/24-09:42:00.367125 7fdf5affd6c0 Manual compaction at level-0 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
2026/05/24-09:42:00.367348 7fdf5affd6c0 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/23-18:45:10.252202 7fa2337fe6c0 Recovering log #310
2026/05/23-18:45:10.261300 7fa2337fe6c0 Delete type=3 #308
2026/05/23-18:45:10.261343 7fa2337fe6c0 Delete type=0 #310
2026/05/23-19:09:56.467263 7fa2327fc6c0 Level-0 table #315: started
2026/05/23-19:09:56.467308 7fa2327fc6c0 Level-0 table #315: 0 bytes OK
2026/05/23-19:09:56.509084 7fa2327fc6c0 Delete type=0 #313
2026/05/23-19:09:56.608544 7fa2327fc6c0 Manual compaction at level-0 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
2026/05/23-19:09:56.665018 7fa2327fc6c0 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-000615
-8
View File
@@ -1,8 +0,0 @@
2026/05/24-09:22:43.141936 7fdf5bfff6c0 Recovering log #613
2026/05/24-09:22:43.189387 7fdf5bfff6c0 Delete type=3 #611
2026/05/24-09:22:43.189438 7fdf5bfff6c0 Delete type=0 #613
2026/05/24-09:42:00.311436 7fdf5affd6c0 Level-0 table #618: started
2026/05/24-09:42:00.311459 7fdf5affd6c0 Level-0 table #618: 0 bytes OK
2026/05/24-09:42:00.318964 7fdf5affd6c0 Delete type=0 #616
2026/05/24-09:42:00.334187 7fdf5affd6c0 Manual compaction at level-0 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
2026/05/24-09:42:00.334334 7fdf5affd6c0 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/23-18:45:10.238924 7fa233fff6c0 Recovering log #609
2026/05/23-18:45:10.250309 7fa233fff6c0 Delete type=3 #607
2026/05/23-18:45:10.250371 7fa233fff6c0 Delete type=0 #609
2026/05/23-19:09:56.224507 7fa2327fc6c0 Level-0 table #614: started
2026/05/23-19:09:56.224539 7fa2327fc6c0 Level-0 table #614: 0 bytes OK
2026/05/23-19:09:56.261516 7fa2327fc6c0 Delete type=0 #612
2026/05/23-19:09:56.339172 7fa2327fc6c0 Manual compaction at level-0 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
2026/05/23-19:09:56.339199 7fa2327fc6c0 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; min-width: 6rem;
max-width: 6rem; max-width: 6rem;
} }
min-width: 10rem; min-width: 11rem;
max-width: 10rem; max-width: 11rem;
.input { .input {
min-width: 2.5rem; min-width: 3.5rem;
max-width: 2.5rem; max-width: 3.5rem;
} }
} }
.granted { .granted {
+60
View File
@@ -89,6 +89,66 @@
font-size: 0.9rem; 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 */ /* Dice Tray — injected into the Foundry chat sidebar */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
+2 -5
View File
@@ -647,14 +647,10 @@
.damage-buttons-grid { .damage-buttons-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
&.monster-damage {
grid-template-columns: 1fr; grid-template-columns: 1fr;
max-width: 280px; max-width: 280px;
margin: 0 auto; margin: 0 auto;
} gap: 8px;
.damage-roll-btn { .damage-roll-btn {
padding: 10px 14px; padding: 10px 14px;
@@ -847,6 +843,7 @@
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 8px; 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> <i class="fa-solid fa-shield-halved" data-roll-type="weapon-defense" data-roll-key="{{item.id}}"></i>
</a> </a>
<a class="rollable" data-roll-type="weapon-damage-small" data-roll-key="{{item.id}}" <a class="rollable" data-roll-type="weapon-damage" data-roll-key="{{item.id}}"
data-tooltip="Roll Damage (Small)"> data-tooltip="Roll Damage">
<i class="fa-regular fa-face-head-bandage" data-roll-type="weapon-damage-small" <i class="fa-regular fa-face-head-bandage" data-roll-type="weapon-damage"
data-roll-key="{{item.id}}"></i>S data-roll-key="{{item.id}}"></i>
</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> </a>
</div> </div>
+3 -22
View File
@@ -69,16 +69,10 @@
</div> </div>
{{/if}} {{/if}}
{{#if rollData.damageSmall}} {{#if rollData.isDamage}}
<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}}
<div class="detail-badge damage-badge"> <div class="detail-badge damage-badge">
<i class="fa-solid fa-dice-d20"></i> <i class="fa-solid fa-dice-d20"></i>
<span>{{localize "LETHALFANTASY.Label.weapon-damage-medium"}}</span> <span>{{localize "LETHALFANTASY.Label.weapon-damage"}}</span>
</div> </div>
{{/if}} {{/if}}
@@ -202,19 +196,6 @@
}}+{{weaponDamageOptions.damageModifier}}{{/if}} }}+{{weaponDamageOptions.damageModifier}}{{/if}}
</button> </button>
{{else}} {{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}} {{#if weaponDamageOptions.damageM}}
<button <button
class="damage-roll-btn" class="damage-roll-btn"
@@ -225,7 +206,7 @@
title="{{localize 'LETHALFANTASY.Label.rollDamage'}}" title="{{localize 'LETHALFANTASY.Label.rollDamage'}}"
> >
<i class="fa-solid fa-dice-d20"></i> <i class="fa-solid fa-dice-d20"></i>
Damage Medium {{localize "LETHALFANTASY.Label.damage"}}
</button> </button>
{{/if}} {{/if}}
{{/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>
+1 -2
View File
@@ -123,7 +123,6 @@
</select> </select>
{{#if (eq rollType "save")}} {{#if (eq rollType "save")}}
{{#if rollTarget.magicUser}}
<div> <div>
<span>Save against spell (+{{rollTarget.actorModifiers.saveModifier}}) <span>Save against spell (+{{rollTarget.actorModifiers.saveModifier}})
?</span> ?</span>
@@ -131,10 +130,10 @@
type="checkbox" type="checkbox"
name="saveSpellCheck" name="saveSpellCheck"
data-action="saveSpellCheck" data-action="saveSpellCheck"
{{#if saveSpell}}checked{{/if}}
/> />
</div> </div>
{{/if}} {{/if}}
{{/if}}
</fieldSet> </fieldSet>
{{/if}} {{/if}}
+2
View File
@@ -6,6 +6,8 @@
{{formField systemFields.level value=system.level}} {{formField systemFields.level value=system.level}}
{{formField systemFields.cost value=system.cost}} {{formField systemFields.cost value=system.cost}}
{{formField systemFields.costOverpowered value=system.costOverpowered}}
{{formField systemFields.costOverpowered2 value=system.costOverpowered2}}
<label>{{localize "LETHALFANTASY.Label.components"}}</label> <label>{{localize "LETHALFANTASY.Label.components"}}</label>
<div class="shift-right"> <div class="shift-right">
+1 -5
View File
@@ -17,11 +17,7 @@
{{formField systemFields.damageType.fields.typeS value=system.damageType.typeS}} {{formField systemFields.damageType.fields.typeS value=system.damageType.typeS}}
</div> </div>
<label>{{localize "LETHALFANTASY.Label.damage"}}</label> {{formField systemFields.damage.fields.damageM value=system.damage.damageM label=(localize "LETHALFANTASY.Label.damage")}}
<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.applyStrengthDamageBonus value=system.applyStrengthDamageBonus localize=true}} {{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');